Skip to main content

dbmd_core/
linkmd.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! The **link.md client** — the five interconnect verbs `dbmd` speaks against
4//! a hub: `resolve`, `sync`, `grant`, `propose`, `subscribe`.
5//!
6//! One binary, two specs (the git precedent: one binary carries both the
7//! object format and the wire protocol). The db.md FORMAT is untouched by this
8//! module: a store never needs link.md to be valid db.md, record files stay
9//! plain markdown, and SPEC.md reserves only the `@brain/id` address *shape*.
10//! Everything with a wire or a trust boundary — addressing across stores,
11//! pulling/pushing a hosted copy, capability grants, the propose door, feed
12//! polling and signed-entry verification — lives here, as a *client
13//! capability*, never a format requirement.
14//!
15//! # What this client speaks
16//!
17//! The v0 HTTP binding a hub serves under its base URL:
18//!
19//! | verb | binding |
20//! | --- | --- |
21//! | `resolve` | `GET /api/hub/brains/<brain>` (the brain card); records are read from the exact signed snapshot pack named by that card's verified feed head |
22//! | `sync` (pull) | `GET /api/hub/brains/<brain>/export?format=pack&atSeq=<n>&feedHash=<hash>` — the exact verified snapshot |
23//! | `sync` (push) | `POST /api/hub/brains/<brain>/push` for small snapshots; presign/upload/commit for large snapshots |
24//! | `grant` | `GET` / `POST /api/hub/brains/<brain>/grants`, `DELETE /api/hub/brains/<brain>/grants/<id>` |
25//! | `propose` | `POST /api/hub/sites/<handle>/inbox` — evidence in, without trust (unauthenticated by design) |
26//! | `subscribe` | `GET /api/hub/brains/<brain>` + `/feed` for a locally verified signed head |
27//!
28//! # Configuration — no default hub, credential never in the store
29//!
30//! There is **no built-in hub endpoint**: the toolkit is neutral and a hub is
31//! whatever the user points it at. Resolution order for the hub URL:
32//!
33//! 1. the `--hub <URL>` flag,
34//! 2. the `DBMD_HUB_URL` environment variable,
35//! 3. the `hub = <URL>` line in the store-local `.dbmd/config` file
36//!    (toolkit state, not store content — the walkers already skip hidden
37//!    directories, so `.dbmd/` never syncs, indexes, or validates).
38//!
39//! The credential is the `DBMD_HUB_KEY` environment variable, full stop. It is
40//! deliberately **not** read from `.dbmd/config`: a secret inside the store
41//! tree is one commit or one push away from leaking. A store-selected hub may
42//! receive an ambient bearer or agent key only when
43//! `DBMD_HUB_CREDENTIAL_ORIGIN` binds it to that exact origin. Identity pins
44//! and monotonic feed checkpoints live in the user's global state directory,
45//! never in store-controlled `.dbmd/`.
46//!
47//! Non-HTTPS hubs are refused (the bearer key must never travel in cleartext)
48//! with a loopback exemption for local development.
49//!
50//! # v0 honesty
51//!
52//! This client binds to what a hub enforces **today**: grantees are hub
53//! principals (an email), grant scopes are store-path prefixes, pushes are
54//! whole-store snapshots, and `subscribe` reports feed-head movement. The hub
55//! signs each committed snapshot in a hash-chained feed with a per-brain
56//! Ed25519 identity. This client verifies the rotation chain, signer epochs,
57//! monotonic feed checkpoint, snapshot token, and content-addressed pack before
58//! untrusted bytes touch their destination.
59
60use std::collections::{BTreeMap, BTreeSet};
61use std::io::{Cursor, Read, Write};
62use std::path::{Path, PathBuf};
63use std::time::{SystemTime, UNIX_EPOCH};
64
65use base64::{
66    engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
67    Engine as _,
68};
69use ring::signature::{UnparsedPublicKey, ED25519};
70use serde::{Deserialize, Serialize};
71use serde_json::{json, Value};
72use sha2::{Digest, Sha256};
73
74use crate::store::Store;
75
76/// Environment variable naming the hub base URL (e.g. `https://hub.example.com`).
77pub const HUB_URL_ENV: &str = "DBMD_HUB_URL";
78
79/// Environment variable carrying the hub bearer credential. The bearer
80/// credential source — see the module docs for why it is never store-based.
81pub const HUB_KEY_ENV: &str = "DBMD_HUB_KEY";
82
83/// Explicit origin binding required before an ambient bearer or agent key may
84/// be sent to a hub selected by untrusted store-local configuration. The value
85/// is an origin (`https://host[:port]`), not an arbitrary URL path.
86pub const HUB_CREDENTIAL_ORIGIN_ENV: &str = "DBMD_HUB_CREDENTIAL_ORIGIN";
87
88/// Override for dbmd's user-owned trust/checkpoint root. Must be absolute.
89/// Intended for managed installations and hermetic tests; untrusted stores
90/// cannot select it.
91pub const STATE_DIR_ENV: &str = "DBMD_STATE_DIR";
92
93/// Explicit test/development escape hatch for registry homes on loopback or
94/// private networks. Production federation rejects every non-public resolved
95/// address and pins the validated DNS answer into the HTTP agent.
96pub const ALLOW_PRIVATE_REGISTRY_HOME_ENV: &str = "DBMD_ALLOW_PRIVATE_REGISTRY_HOME";
97
98/// Explicit development escape hatch for object-store URLs on loopback/private
99/// networks. Production hubs must return public HTTPS URLs. A local loopback hub
100/// is allowed to use local object URLs without this switch.
101pub const ALLOW_PRIVATE_OBJECT_URL_ENV: &str = "DBMD_ALLOW_PRIVATE_OBJECT_URL";
102
103/// Environment variable naming the PATH of a self-custodied BRAIN key file
104/// (link.md §2.4). When set, `sync --push` signs each feed entry locally and
105/// ships it through the pack flow — the hub verifies and stores the exact
106/// client bytes and can never sign for the brain. Same file format as agent
107/// keys (`dbmd key generate`).
108pub const BRAIN_KEY_FILE_ENV: &str = "DBMD_BRAIN_KEY_FILE";
109
110/// Environment variable naming the PATH of an agent signing key file
111/// (link.md §8 `LinkMD-Sig` proof of possession). When set, authenticated
112/// requests are signed per-request with the agent's Ed25519 key instead of
113/// carrying a bearer: the signature binds method + path + body + a ±60s
114/// window, so nothing reusable ever crosses the wire or lands in a log or an
115/// agent transcript. The file holds the base64url PKCS#8 key minted by
116/// `dbmd key generate`; the path is not a secret, the file is (mode 0600).
117pub const AGENT_KEY_FILE_ENV: &str = "DBMD_AGENT_KEY_FILE";
118
119/// The store-local config file, relative to the store root. Holds non-secret
120/// toolkit state (`hub = <URL>`); hidden, so every store walk skips it.
121pub const CONFIG_REL_PATH: &str = ".dbmd/config";
122
123/// Control-plane JSON is metadata, never the store itself. Exact snapshot
124/// bytes travel through the separately bounded pack lane.
125const MAX_RESPONSE_BYTES: u64 = 8 * 1024 * 1024;
126/// A feed page can legitimately carry a large snapshot manifest, but remains
127/// far below a full pack and is parsed with count-bounded sequence visitors.
128const MAX_FEED_RESPONSE_BYTES: u64 = 16 * 1024 * 1024;
129/// Foreign registry cards are identity metadata only.
130const MAX_REGISTRY_CARD_BYTES: u64 = 1024 * 1024;
131
132/// Direct JSON pushes stay below the serverless request-body cap. Larger
133/// snapshots switch to the bounded object-store pack lane.
134const MAX_PUSH_BYTES: usize = 4 * 1024 * 1024;
135/// Ceiling for a staged change manifest. Mirrors the hub's bound so an
136/// oversized push is refused before it stages anything.
137const MAX_STAGED_CHANGE_BYTES: usize = 64 * 1024 * 1024;
138
139/// Canonical raw ZIP32 uses a u16 entry count and stores each UTF-8 name twice.
140const MAX_PUSH_FILES: usize = u16::MAX as usize;
141const MAX_STORE_PATH_BYTES: usize = 1_024;
142const MAX_STORE_BYTES: u64 = 512 * 1024 * 1024;
143/// Exact worst case for the canonical STORED profile:
144/// payload + (local 30 + central 46 + name twice) per entry + EOCD 22.
145const MAX_PACK_BYTES: u64 =
146    MAX_STORE_BYTES + MAX_PUSH_FILES as u64 * (76 + 2 * MAX_STORE_PATH_BYTES) as u64 + 22;
147/// An upload reservation body carries one entry per changed blob, so a real
148/// store's first push does not fit in a single request: the hub bounds the body
149/// and the hosting platform rejects an oversized payload before the route sees
150/// it. Reservations are therefore batched. The budget is deliberately well
151/// under the hub's own ceiling, and both bounds are enforced because a single
152/// hash can carry many coordinates and a path may be long.
153/// The count matches the reservation window a hub grants per request; batching
154/// above it only fails deeper in, after the body has already been accepted.
155const MAX_UPLOAD_RESERVATION_BYTES: usize = 1024 * 1024;
156const MAX_UPLOAD_RESERVATION_BLOBS: usize = 1_000;
157
158/// A legitimate brain should rotate rarely. Bound adversarial identity
159/// histories before allocating and repeatedly verifying an unbounded chain.
160const MAX_IDENTITY_ROTATIONS: usize = 1_024;
161
162/// Split blob declarations into request-sized batches, by serialized size and
163/// by count. Order is preserved so a caller can concatenate the responses.
164fn batch_upload_declarations(declarations: Vec<Value>) -> Vec<Vec<Value>> {
165    let mut batches: Vec<Vec<Value>> = Vec::new();
166    let mut current: Vec<Value> = Vec::new();
167    let mut current_bytes = 0usize;
168    for declaration in declarations {
169        let declared_bytes = serde_json::to_string(&declaration)
170            .map(|text| text.len())
171            .unwrap_or(MAX_UPLOAD_RESERVATION_BYTES)
172            + 1;
173        if !current.is_empty()
174            && (current.len() >= MAX_UPLOAD_RESERVATION_BLOBS
175                || current_bytes + declared_bytes > MAX_UPLOAD_RESERVATION_BYTES)
176        {
177            batches.push(std::mem::take(&mut current));
178            current_bytes = 0;
179        }
180        current_bytes += declared_bytes;
181        current.push(declaration);
182    }
183    if !current.is_empty() {
184        batches.push(current);
185    }
186    batches
187}
188/// A client never needs to replay an unbounded feed in one invocation. Large
189/// histories are mirrored incrementally; a first mirror beyond this cap needs a
190/// checkpoint/export rather than allocating attacker-controlled metadata.
191const MAX_FEED_REPLAY_ENTRIES: u64 = 100_000;
192const MAX_FEED_REPLAY_BYTES: u64 = 64 * 1024 * 1024;
193const FEED_PAGE_LIMIT: usize = 100;
194
195/// The hub's inbox cap on one `propose` submission body, mirrored client-side
196/// so an oversized body fails before the upload, not after (the same
197/// fail-before-upload contract as the push caps). Public so the CLI can
198/// pre-check a `--body-file` from file metadata without reading it.
199pub const MAX_PROPOSE_BYTES: u64 = 16 * 1024;
200
201/// Bounded connect so a dead hub fails fast; a generous read window so a
202/// large export on a slow link still completes.
203const CONNECT_TIMEOUT_SECS: u64 = 10;
204const READ_TIMEOUT_SECS: u64 = 120;
205/// Hard wall-clock budget for one HTTP attempt, spanning socket writes,
206/// response headers, and the complete response body. DNS performed for pinned
207/// agents has its own interruptible deadline below.
208const OVERALL_REQUEST_TIMEOUT_SECS: u64 = 120;
209/// A commit of a whole store is brain-sized work the hub is allowed to spend
210/// its full route budget on, so the client has to outwait the server rather
211/// than the other way round. At the default 120 seconds a first push of a real
212/// store abandoned a commit the hub was still completing and reported a
213/// transport failure for work that had not failed. This sits above the hub's
214/// own genesis-commit ceiling so the client always learns the outcome.
215const COMMIT_REQUEST_TIMEOUT_SECS: u64 = 900;
216/// A commit is idempotent by mutation coordinate, so a lost response is worth
217/// asking after — and the work it lost the receipt for may still be finishing,
218/// which is why the waits are long enough to let a brain-sized commit land.
219const COMMIT_ATTEMPTS: usize = 4;
220const COMMIT_RETRY_BACKOFF_MS: [u64; COMMIT_ATTEMPTS - 1] = [5_000, 20_000, 45_000];
221const CONNECT_ATTEMPTS: usize = 3;
222const CONNECT_RETRY_BACKOFF_MS: [u64; CONNECT_ATTEMPTS - 1] = [100, 300];
223
224/// Staging a real store means tens of thousands of immutable object PUTs in a
225/// row. A single refusal under sustained load must not fail the migration, so
226/// the upload lane retries further and waits longer than a connect blip does.
227const UPLOAD_ATTEMPTS: usize = 6;
228const UPLOAD_RETRY_BACKOFF_MS: [u64; UPLOAD_ATTEMPTS - 1] = [200, 600, 1_500, 3_000, 6_000];
229
230fn upload_retry_backoff_ms(attempt: usize) -> u64 {
231    UPLOAD_RETRY_BACKOFF_MS[attempt.min(UPLOAD_RETRY_BACKOFF_MS.len() - 1)]
232}
233
234/// A migration opens dozens of staging windows back to back, so it is ordinary
235/// for a shared limiter or a briefly busy hub to answer one of them with 429 or
236/// 5xx. Failing the push there throws away every byte already staged, and the
237/// waits have to reach past a whole limiter window to be worth anything.
238const RESERVATION_ATTEMPTS: usize = 7;
239const RESERVATION_BACKOFF_MS: [u64; RESERVATION_ATTEMPTS - 1] =
240    [500, 2_000, 5_000, 15_000, 30_000, 60_000];
241
242/// Answers a busy or briefly unhealthy hub gives, as opposed to a refusal that
243/// would repeat identically. A 4xx other than these states something about the
244/// request itself and is never retried.
245fn is_retryable_hub_status(status: u16) -> bool {
246    matches!(status, 408 | 429 | 500 | 502 | 503 | 504)
247}
248
249/// Statuses worth replaying an immutable, content-addressed upload for: edge
250/// and object-store refusals that are load-shaped rather than a statement about
251/// these bytes. A checksum or precondition failure is never retried.
252fn is_retryable_upload_status(status: u16) -> bool {
253    matches!(status, 400 | 408 | 429 | 500 | 502 | 503 | 504)
254}
255/// Cold checkouts may need every visible blob, but short-lived direct object
256/// capabilities must not create an unbounded connection storm. Keep the
257/// verified downloads inside this fixed worker count.
258const V2_BLOB_DOWNLOAD_WORKERS: usize = 16;
259/// File durability barriers are independent inside the private pull stage.
260/// Keep enough in flight to hide storage flush latency without approaching
261/// ordinary process descriptor limits.
262#[cfg(unix)]
263const V2_PULL_INSTALL_WORKERS: usize = 16;
264/// One authenticated link.md v2 bulk frame remains small enough for bounded
265/// retries and serverless streaming while amortizing tens of thousands of
266/// immutable blobs over a few hundred requests instead of one request each.
267const V2_BULK_STREAM_FILES: usize = 256;
268const V2_BULK_STREAM_CONTENT_BYTES: u64 = 8 * 1024 * 1024;
269const V2_BULK_STREAM_RESPONSE_BYTES: u64 = 10 * 1024 * 1024;
270const V2_BULK_STREAM_MAGIC: &[u8; 8] = b"LMD2STRM";
271
272/// Everything that can go wrong on the wire or at its edges. Each variant maps
273/// onto one stable CLI error code; messages are single-line and never echo the
274/// credential.
275#[derive(Debug, thiserror::Error)]
276pub enum LinkError {
277    /// No hub URL was configured anywhere (flag, env, `.dbmd/config`).
278    #[error(
279        "no hub configured — pass --hub <URL>, set {HUB_URL_ENV}, or add `hub = <URL>` to {CONFIG_REL_PATH}"
280    )]
281    NoHub,
282
283    /// The verb needs a credential and none was present.
284    #[error("no hub credential — set {HUB_KEY_ENV} (credentials never live in {CONFIG_REL_PATH})")]
285    NoCredential,
286
287    /// The credential contains whitespace / non-ASCII (a paste artifact). The
288    /// key is deliberately not echoed.
289    #[error(
290        "the hub credential in {HUB_KEY_ENV} contains whitespace or non-ASCII characters — re-copy it (the key is not shown here on purpose)"
291    )]
292    BadKey,
293
294    /// A store selected the destination while an ambient credential was
295    /// present, but the operator did not bind that credential to the same
296    /// origin. This is a hard refusal, not an anonymous fallback: silently
297    /// dropping a credential can turn an intended private operation into a
298    /// confusing public one.
299    #[error(
300        "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}"
301    )]
302    UnboundCredential,
303
304    /// The agent signing key file named by [`AGENT_KEY_FILE_ENV`] is missing,
305    /// unreadable, or not a valid Ed25519 PKCS#8 — key material is never
306    /// echoed.
307    #[error("invalid agent signing key ({message}) — mint one with `dbmd key generate`")]
308    BadAgentKey {
309        /// What failed, without any key material.
310        message: String,
311    },
312
313    /// A non-HTTPS hub outside loopback: the bearer key would travel in cleartext.
314    #[error("refusing non-HTTPS hub {hub} — the credential would travel in cleartext (localhost is exempt)")]
315    UnsafeHub {
316        /// The offending hub URL.
317        hub: String,
318    },
319
320    /// TCP/TLS-level failure: the hub never answered.
321    #[error("hub unreachable at {hub}: {message}")]
322    Transport {
323        /// The hub base URL.
324        hub: String,
325        /// The transport-layer error text.
326        message: String,
327    },
328
329    /// The hub answered with an HTTP error status.
330    #[error("{what} failed (HTTP {status}): {message}")]
331    Http {
332        /// What the client was doing (e.g. `"resolve"`, `"sync pull"`).
333        what: &'static str,
334        /// The HTTP status code.
335        status: u16,
336        /// The hub's own `error` string when it sent one, else a placeholder.
337        message: String,
338        /// The hub's machine `code` field when it sent one.
339        code: Option<String>,
340        /// Permission-filtered structured refusal details, when supplied.
341        details: Option<Value>,
342    },
343
344    /// A 2xx whose body is not JSON — a captive portal, a proxy, or a wrong
345    /// URL — refused here rather than deserializing into nothing downstream.
346    #[error("{what}: the hub answered HTTP {status} with a non-JSON body — check the hub URL")]
347    NotJson {
348        /// What the client was doing.
349        what: &'static str,
350        /// The (2xx) status that carried the non-JSON body.
351        status: u16,
352    },
353
354    /// The hub response exceeded the selected endpoint's byte cap.
355    #[error("hub response exceeded the {limit_bytes}-byte endpoint cap — refusing to buffer it")]
356    ResponseTooLarge {
357        /// The endpoint-specific cap applied before JSON parsing.
358        limit_bytes: u64,
359    },
360
361    /// A malformed `@brain/id` address.
362    #[error("invalid address `{given}`: {reason}")]
363    BadAddress {
364        /// The raw address as typed.
365        given: String,
366        /// Why it did not parse.
367        reason: String,
368    },
369
370    /// A grant id whose shape cannot travel as a URL path segment.
371    #[error(
372        "invalid grant id `{given}` — grant ids come from `grant list` (lowercase letters, digits, hyphens)"
373    )]
374    BadGrantId {
375        /// The raw id as typed.
376        given: String,
377    },
378
379    /// An exported file path that would escape or pollute the destination
380    /// (absolute, `..`, a dot-leading segment, or an illegal character). The
381    /// hub is not trusted with local path layout.
382    #[error("refusing unsafe path from the hub: `{path}`")]
383    UnsafePath {
384        /// The offending path as received.
385        path: String,
386    },
387
388    /// The store exceeds the hub's bounded whole-snapshot caps.
389    #[error(
390        "push too large ({detail}) — one snapshot caps at {} MB uncompressed, {} MB as a pack, and {MAX_PUSH_FILES} files",
391        MAX_STORE_BYTES / (1024 * 1024),
392        MAX_PACK_BYTES / (1024 * 1024)
393    )]
394    PushTooLarge {
395        /// Which cap was hit, human-readable.
396        detail: String,
397    },
398
399    /// The propose body exceeds the hub's inbox cap.
400    #[error(
401        "propose body too large ({bytes} bytes) — the hub's inbox caps one submission at {} KB",
402        MAX_PROPOSE_BYTES / 1024
403    )]
404    ProposeTooLarge {
405        /// The offending body size in bytes.
406        bytes: u64,
407    },
408
409    /// A store file that is not valid UTF-8 cannot travel the JSON push path.
410    #[error("store file `{path}` is not valid UTF-8 — the JSON push path carries text only")]
411    NotUtf8 {
412        /// The store-relative path of the offending file.
413        path: String,
414    },
415
416    /// A downloaded pack failed validation before any local write.
417    #[error("invalid store pack: {message}")]
418    InvalidPack {
419        /// Hash, ZIP, path, count, or expansion failure.
420        message: String,
421    },
422
423    /// A signed feed entry, hash chain, or advertised feed head did not verify.
424    #[error("invalid signed feed: {message}")]
425    InvalidFeed {
426        /// The failed integrity condition, without untrusted secret material.
427        message: String,
428    },
429
430    /// A mutable alias moved to another canonical brain. Ordinary sync fails
431    /// closed; only the exact old/new ids can authorize replacing that one
432    /// alias binding while preserving both canonical trust histories.
433    #[error(
434        "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}`"
435    )]
436    AliasRebindRequired {
437        alias: String,
438        from: String,
439        to: String,
440    },
441
442    /// Local and remote both changed one or more coordinates since the last
443    /// verified sync baseline. No side was overwritten.
444    #[error("sync conflict on {paths:?} — resolve the named files and retry")]
445    Conflict {
446        /// Bounded, portable paths safe to show to the agent/operator.
447        paths: Vec<String>,
448    },
449
450    /// A readable v2 conflict was preserved as a private, exact-head bundle.
451    /// The bundle is local control state; none of its bytes ride with the
452    /// brain, and resolving it always creates a fresh explicit mutation.
453    #[error(
454        "sync conflict preserved in private bundle `{bundle}` for {paths:?} — run `dbmd sync resolve {bundle} --keep-local`, `--take-remote`, or `--from <safe-file>`"
455    )]
456    ConflictBundle {
457        /// Local ULID naming `.dbmd/conflicts/<bundle>/plan.json`.
458        bundle: String,
459        /// Bounded, readable conflict paths.
460        paths: Vec<String>,
461    },
462
463    /// `.sevralocal` newly made paths eligible to ride. Uploading them is an
464    /// explicit adoption boundary, never an accidental side effect of editing
465    /// or removing a local policy file.
466    #[error(
467        "local sync policy newly exposes {paths:?} — review and retry with --resume-local-policy"
468    )]
469    LocalPolicyTransition {
470        /// Bounded local-only paths. They are never sent in telemetry.
471        paths: Vec<String>,
472    },
473
474    /// The exact mutation crosses a permissioned bulk-impact boundary. The
475    /// hub has not committed it; `preview` is the bounded, permission-filtered
476    /// receipt an agent must inspect before explicitly confirming the same
477    /// request.
478    #[error(
479        "bulk change requires explicit confirmation — review the preview and retry the same sync with --confirm-bulk <id>:<digest>"
480    )]
481    BulkPreviewRequired {
482        /// Stable structured preview returned by the hub.
483        preview: Value,
484    },
485
486    /// A scoped checkout's generated store marker was edited or removed.
487    /// It is local projection metadata and is never accepted as brain data.
488    #[error(
489        "the generated DB.md for this scoped view was modified — clone a fresh scoped checkout"
490    )]
491    ScopedProjectionModified,
492
493    /// The effective permission slice changed after this checkout was pinned.
494    /// Reusing the directory could conceal removals or accidentally adopt files
495    /// revealed by a wider grant, so a new checkout is required.
496    #[error(
497        "the checkout's permission scope changed — clone into a new directory to accept the new view"
498    )]
499    ScopedViewChanged,
500
501    /// A v2 identity/head was previously accepted for this ref, but the hub
502    /// now hides it. Never reinterpret that as a v1 downgrade.
503    #[error("the previously verified brain is unavailable — access may have been revoked or the brain removed")]
504    BrainUnavailable,
505
506    /// The verified remote head advanced while a pull or post-commit barrier
507    /// was in flight. The old baseline is deliberately retained.
508    #[error(
509        "the remote brain advanced during sync — retry to converge from the new verified head"
510    )]
511    RemoteAdvancedDuringSync,
512
513    /// This build cannot provide the no-follow, directory-handle-relative
514    /// filesystem semantics required for trust/key/snapshot state.
515    #[error(
516        "{operation} is unavailable on this platform — use the official macOS/Linux build or WSL"
517    )]
518    UnsupportedPlatform {
519        /// The operation that requires hardened local filesystem primitives.
520        operation: &'static str,
521    },
522
523    /// Local filesystem failure while materializing a pull or reading a push.
524    #[error(transparent)]
525    Io(#[from] std::io::Error),
526
527    /// A store-level failure (walking the local store for a push).
528    #[error(transparent)]
529    Store(#[from] crate::StoreError),
530}
531
532/// Result alias for link.md client operations.
533pub type LinkResult<T> = std::result::Result<T, LinkError>;
534
535/// Exact approval token returned by a permissioned v2 bulk preview.
536#[derive(Debug, Clone, PartialEq, Eq)]
537pub struct V2BulkConfirmation {
538    /// Opaque preview receipt id.
539    pub id: String,
540    /// Digest binding the receipt to principal, head, control revision, and
541    /// the exact strict mutation.
542    pub digest: String,
543}
544
545impl V2BulkConfirmation {
546    /// Parse the CLI/wire handoff form `<id>:<digest>` and reject anything
547    /// outside the protocol's exact lowercase shapes before network I/O.
548    pub fn parse(value: &str) -> LinkResult<Self> {
549        let (id, digest) = value
550            .split_once(':')
551            .ok_or_else(|| LinkError::InvalidPack {
552                message: "bulk confirmation must be <id>:<digest>".to_string(),
553            })?;
554        if !crate::ulid::is_ulid(id) || !is_sha256(digest) {
555            return Err(LinkError::InvalidPack {
556                message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
557                    .to_string(),
558            });
559        }
560        Ok(Self {
561            id: id.to_string(),
562            digest: digest.to_string(),
563        })
564    }
565}
566
567/// Security-sensitive link.md state and destination writes require a hardened
568/// descriptor/handle-relative filesystem implementation. Unix uses
569/// `openat`/`O_NOFOLLOW`; Windows uses held directory handles, reparse-point
570/// refusal, exclusive share modes, and write-through replacement.
571fn require_hardened_filesystem(operation: &'static str) -> LinkResult<()> {
572    #[cfg(any(target_os = "linux", target_os = "macos", windows))]
573    {
574        let _ = operation;
575        Ok(())
576    }
577    #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
578    {
579        Err(LinkError::UnsupportedPlatform { operation })
580    }
581}
582
583// ─────────────────────────────────────────────────────────────────────────────
584// Addressing — `@brain[/id]`, the reserved shape (SPEC § Addressing)
585// ─────────────────────────────────────────────────────────────────────────────
586
587/// What the part after `@brain/` names.
588#[derive(Debug, Clone, PartialEq, Eq)]
589pub enum AddressTarget {
590    /// A record `id` — the db.md lowercase ULID (the reserved `@brain/id` shape).
591    Id(String),
592    /// A store-relative `.md` path — a client-side convenience the hub's
593    /// resolve endpoint also accepts (`?path=`). Not part of the reserved
594    /// shape; unambiguous because a ULID is never a path.
595    Path(String),
596}
597
598/// Why a brain reference failed [`is_safe_ref`] — shared by [`Address::parse`]
599/// and the per-verb entry gates so the two surfaces never drift.
600const BAD_BRAIN_REASON: &str =
601    "the brain reference must be a brain id (lowercase ULID) or a slug (lowercase letters, digits, hyphens)";
602
603/// Why an address target failed its shape check — shared by [`Address::parse`]
604/// and the [`resolve`] entry gate.
605const BAD_TARGET_REASON: &str =
606    "the part after `/` must be a record id (lowercase ULID) or a store-relative `.md` path";
607
608/// A parsed `@brain[/target]` address. `brain` is a hub brain reference — the
609/// brain's ULID id (works for any caller, including cross-party on a public
610/// brain) or a slug (which a hub resolves only against the caller's own
611/// brains; slugs are unique per owner, not globally).
612#[derive(Debug, Clone, PartialEq, Eq)]
613pub struct Address {
614    /// The brain reference (leading `@` stripped).
615    pub brain: String,
616    /// The record target, when the address names one.
617    pub target: Option<AddressTarget>,
618}
619
620impl Address {
621    /// Parse `@brain`, `@brain/<ulid>`, or `@brain/<store-path>.md`. The `@`
622    /// sigil is optional (an agent piping ids around should not have to quote
623    /// it back on). Whitespace and empty segments are malformed.
624    pub fn parse(raw: &str) -> LinkResult<Address> {
625        let bad = |reason: &str| LinkError::BadAddress {
626            given: raw.to_string(),
627            reason: reason.to_string(),
628        };
629
630        let trimmed = raw.trim();
631        let body = trimmed.strip_prefix('@').unwrap_or(trimmed);
632        if body.is_empty() {
633            return Err(bad("empty address"));
634        }
635
636        let (brain, rest) = match body.split_once('/') {
637            Some((b, r)) => (b, Some(r)),
638            None => (body, None),
639        };
640
641        if brain.is_empty() {
642            return Err(bad("missing brain reference before `/`"));
643        }
644        if !is_safe_ref(brain) {
645            return Err(bad(BAD_BRAIN_REASON));
646        }
647
648        let target = match rest {
649            None => None,
650            Some("") => return Err(bad("trailing `/` with no record id or path")),
651            Some(r) if crate::ulid::is_ulid(r) => Some(AddressTarget::Id(r.to_string())),
652            Some(r) => {
653                if !safe_store_rel_path(r) || !r.ends_with(".md") {
654                    return Err(bad(BAD_TARGET_REASON));
655                }
656                Some(AddressTarget::Path(r.to_string()))
657            }
658        };
659
660        Ok(Address {
661            brain: brain.to_string(),
662            target,
663        })
664    }
665}
666
667/// A brain reference safe to embed in a URL path segment: the shapes a hub
668/// accepts (ULID id or slug), which are also exactly URL-path-clean.
669fn is_safe_ref(s: &str) -> bool {
670    !s.is_empty()
671        && s.len() <= 64
672        && s.bytes()
673            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
674}
675
676/// A published-site handle (the `propose` target). Same lexical shape as a
677/// slug.
678pub fn is_valid_handle(s: &str) -> bool {
679    is_safe_ref(s)
680}
681
682/// True when `p` is a store-relative path this client will read from or write
683/// to disk: relative, no `..`, no empty or dot-leading segment (which shields
684/// `.dbmd/` and `.git/`), and only the hub-portable character set. Applied to
685/// every path an export hands us (the hub is not trusted with local layout)
686/// and to every path a push sends (mirroring the hub's own gate).
687pub fn safe_store_rel_path(p: &str) -> bool {
688    if p.is_empty() || p.len() > MAX_STORE_PATH_BYTES || p.starts_with('/') {
689        return false;
690    }
691    if !p
692        .bytes()
693        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/'))
694    {
695        return false;
696    }
697    p.split('/')
698        .all(|seg| !seg.is_empty() && seg != "." && seg != ".." && !seg.starts_with('.'))
699}
700
701/// Entry gate for every verb that embeds a caller-supplied brain reference in
702/// a URL path segment. `resolve` reaches the same check through
703/// [`Address::parse`]; the raw-ref verbs (`sync`, `grant`, `subscribe`) call
704/// this directly, so a ref carrying `/`, `..`, `?`, `#`, or any other
705/// URL-reshaping byte is refused before a request exists (the `url` crate
706/// normalizes dot segments, so an unvalidated ref would redirect the
707/// authenticated request to a different hub path).
708fn require_safe_ref(brain: &str) -> LinkResult<()> {
709    if is_safe_ref(brain) {
710        Ok(())
711    } else {
712        Err(LinkError::BadAddress {
713            given: brain.to_string(),
714            reason: BAD_BRAIN_REASON.to_string(),
715        })
716    }
717}
718
719/// Entry gate for the published-site handle `propose` embeds in its URL path.
720fn require_valid_handle(handle: &str) -> LinkResult<()> {
721    if is_valid_handle(handle) {
722        Ok(())
723    } else {
724        Err(LinkError::BadAddress {
725            given: handle.to_string(),
726            reason: "the site handle must be lowercase letters, digits, hyphens".to_string(),
727        })
728    }
729}
730
731/// Entry gate for the grant id `grant revoke` embeds in its URL path. Hub
732/// grant ids are lowercase ULIDs; the gate accepts the same URL-path-clean
733/// shape as a brain ref rather than pinning one mint scheme.
734fn require_safe_grant_id(id: &str) -> LinkResult<()> {
735    if is_safe_ref(id) {
736        Ok(())
737    } else {
738        Err(LinkError::BadGrantId {
739            given: id.to_string(),
740        })
741    }
742}
743
744// ─────────────────────────────────────────────────────────────────────────────
745// Configuration — flag > env > .dbmd/config; credential from env only
746// ─────────────────────────────────────────────────────────────────────────────
747
748/// The resolved client configuration for one invocation.
749#[derive(Debug, Clone)]
750pub struct HubConfig {
751    /// The hub base URL, trailing slash stripped, HTTPS-or-loopback enforced.
752    pub hub: String,
753    /// The bearer credential, when the environment carries one.
754    pub key: Option<String>,
755    /// The agent signing key, when [`AGENT_KEY_FILE_ENV`] names one. Wins
756    /// over the bearer for authenticated requests (link.md §8).
757    pub agent_key: Option<AgentSigningKey>,
758    /// The self-custodied brain signing key, when [`BRAIN_KEY_FILE_ENV`]
759    /// names one — `sync --push` then signs feed entries locally (§2.4).
760    pub brain_key: Option<AgentSigningKey>,
761    /// User-owned global toolkit state root. Identity pins and monotonic feed
762    /// checkpoints live below `<state_dir>/trust/`, never under a store.
763    pub state_dir: PathBuf,
764    /// True only when the origin came from untrusted store-local configuration.
765    /// Such origins are resolved and public-IP-pinned for every request.
766    store_selected: bool,
767}
768
769/// A loaded agent signing key: the PKCS#8 secret plus its derived public
770/// multikey. Debug never prints key material.
771#[derive(Clone)]
772pub struct AgentSigningKey {
773    pkcs8: Vec<u8>,
774    /// The key's public identity, `ed25519:<base64url sha256(SPKI)>`.
775    pub multikey: String,
776    /// The full public key, `base64url(SPKI DER)` — what feed entries carry.
777    pub public_key_spki: String,
778}
779
780impl std::fmt::Debug for AgentSigningKey {
781    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
782        f.debug_struct("AgentSigningKey")
783            .field("multikey", &self.multikey)
784            .field("pkcs8", &"<redacted>")
785            .finish()
786    }
787}
788
789impl HubConfig {
790    /// The credential, or the canonical "not configured" error. Verbs that
791    /// authenticate call this; `propose` never does.
792    pub fn require_key(&self) -> LinkResult<&str> {
793        self.key.as_deref().ok_or(LinkError::NoCredential)
794    }
795}
796
797/// Resolve the client configuration: `flag_hub` beats [`HUB_URL_ENV`] beats
798/// the `hub =` line in `<dir>/.dbmd/config`; no fallback default exists. The
799/// credential comes from [`HUB_KEY_ENV`] alone and is validated as a clean
800/// header token (never echoed on failure).
801pub fn hub_config(flag_hub: Option<&str>, dir: &Path) -> LinkResult<HubConfig> {
802    let explicit_hub = flag_hub
803        .map(str::to_string)
804        .or_else(|| env_nonempty(HUB_URL_ENV));
805    let selected_by_store = explicit_hub.is_none();
806    let hub = explicit_hub
807        .or_else(|| config_file_hub(&dir.join(CONFIG_REL_PATH)))
808        .ok_or(LinkError::NoHub)?;
809    let hub = hub.trim().trim_end_matches('/').to_string();
810    assert_safe_hub(&hub)?;
811    if selected_by_store {
812        let parsed =
813            url::Url::parse(&hub).map_err(|_| LinkError::UnsafeHub { hub: hub.clone() })?;
814        // A cloned store is not an operator opt-in to local-network access.
815        // Local/private development hubs must be selected explicitly by flag or
816        // environment, never by bytes inside the store.
817        if !parsed.scheme().eq_ignore_ascii_case("https")
818            || (parsed.path() != "/" && !parsed.path().is_empty())
819        {
820            return Err(LinkError::UnsafeHub { hub });
821        }
822    }
823
824    let key = match env_nonempty(HUB_KEY_ENV) {
825        Some(raw) => Some(clean_key(&raw)?),
826        None => None,
827    };
828
829    let agent_key = match env_nonempty(AGENT_KEY_FILE_ENV) {
830        Some(path) => Some(load_agent_key(Path::new(&path))?),
831        None => None,
832    };
833
834    let brain_key = match env_nonempty(BRAIN_KEY_FILE_ENV) {
835        Some(path) => Some(load_agent_key(Path::new(&path))?),
836        None => None,
837    };
838
839    // A cloned store controls `.dbmd/config`. It must never be able to point
840    // the process at an attacker origin and harvest the user's ambient account
841    // bearer, signed agent identity, or self-custodied brain signatures/content.
842    // Explicit --hub/DBMD_HUB_URL selection already pairs target + credential
843    // in the invocation environment; a store-selected target additionally
844    // needs an exact origin binding.
845    if selected_by_store && (key.is_some() || agent_key.is_some() || brain_key.is_some()) {
846        let bound = env_nonempty(HUB_CREDENTIAL_ORIGIN_ENV)
847            .and_then(|value| normalized_origin(&value).ok());
848        let selected_origin = normalized_origin(&hub)?;
849        if bound.as_deref() != Some(selected_origin.as_str()) {
850            return Err(LinkError::UnboundCredential);
851        }
852    }
853
854    Ok(HubConfig {
855        hub,
856        key,
857        agent_key,
858        brain_key,
859        state_dir: toolkit_state_dir()?,
860        store_selected: selected_by_store,
861    })
862}
863
864fn toolkit_state_dir() -> LinkResult<PathBuf> {
865    if let Some(path) = env_nonempty(STATE_DIR_ENV) {
866        let path = PathBuf::from(path);
867        if !path.is_absolute() {
868            return Err(LinkError::UnsafePath {
869                path: path.display().to_string(),
870            });
871        }
872        return Ok(path);
873    }
874    #[cfg(windows)]
875    if let Some(base) = env_nonempty("LOCALAPPDATA") {
876        let base = PathBuf::from(base);
877        if base.is_absolute() {
878            return Ok(base.join("dbmd").join("state"));
879        }
880    }
881    #[cfg(windows)]
882    {
883        Err(LinkError::Io(std::io::Error::new(
884            std::io::ErrorKind::NotFound,
885            format!("cannot locate user state; set {STATE_DIR_ENV} or LOCALAPPDATA"),
886        )))
887    }
888    #[cfg(not(windows))]
889    if let Some(base) = env_nonempty("XDG_STATE_HOME") {
890        let base = PathBuf::from(base);
891        if base.is_absolute() {
892            return Ok(base.join("dbmd"));
893        }
894    }
895    #[cfg(not(windows))]
896    let home = PathBuf::from(env_nonempty("HOME").ok_or_else(|| {
897        LinkError::Io(std::io::Error::new(
898            std::io::ErrorKind::NotFound,
899            format!("cannot locate user state; set {STATE_DIR_ENV}"),
900        ))
901    })?);
902    #[cfg(not(windows))]
903    if !home.is_absolute() {
904        return Err(LinkError::UnsafePath {
905            path: home.display().to_string(),
906        });
907    }
908    #[cfg(target_os = "macos")]
909    {
910        Ok(home
911            .join("Library")
912            .join("Application Support")
913            .join("dbmd")
914            .join("state"))
915    }
916    #[cfg(all(not(target_os = "macos"), not(windows)))]
917    {
918        Ok(home.join(".local").join("state").join("dbmd"))
919    }
920}
921
922fn normalized_origin(value: &str) -> LinkResult<String> {
923    let parsed = url::Url::parse(value).map_err(|_| LinkError::UnsafeHub {
924        hub: value.to_string(),
925    })?;
926    if !(parsed.scheme().eq_ignore_ascii_case("https")
927        || parsed.scheme().eq_ignore_ascii_case("http"))
928        || !parsed.username().is_empty()
929        || parsed.password().is_some()
930        || (parsed.path() != "/" && !parsed.path().is_empty())
931        || parsed.query().is_some()
932        || parsed.fragment().is_some()
933    {
934        return Err(LinkError::UnsafeHub {
935            hub: value.to_string(),
936        });
937    }
938    let host = parsed.host_str().ok_or_else(|| LinkError::UnsafeHub {
939        hub: value.to_string(),
940    })?;
941    let host = if host.contains(':') {
942        format!("[{host}]")
943    } else {
944        host.to_ascii_lowercase()
945    };
946    let port = parsed
947        .port_or_known_default()
948        .ok_or_else(|| LinkError::UnsafeHub {
949            hub: value.to_string(),
950        })?;
951    let default = (parsed.scheme().eq_ignore_ascii_case("https") && port == 443)
952        || (parsed.scheme().eq_ignore_ascii_case("http") && port == 80);
953    Ok(format!(
954        "{}://{}{}",
955        parsed.scheme().to_ascii_lowercase(),
956        host,
957        if default {
958            String::new()
959        } else {
960            format!(":{port}")
961        }
962    ))
963}
964
965// ─────────────────────────────────────────────────────────────────────────────
966// Agent signing keys — link.md §8 `LinkMD-Sig` proof of possession
967// ─────────────────────────────────────────────────────────────────────────────
968
969/// The DER prefix that wraps a raw Ed25519 public key into a
970/// SubjectPublicKeyInfo (RFC 8410).
971const ED25519_SPKI_PREFIX: [u8; 12] = [
972    0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
973];
974
975fn bad_agent_key(message: &str) -> LinkError {
976    LinkError::BadAgentKey {
977        message: message.to_string(),
978    }
979}
980
981fn agent_keypair(pkcs8: &[u8]) -> LinkResult<ring::signature::Ed25519KeyPair> {
982    // `from_pkcs8` wants ring's own v2 encoding (private + public); keys from
983    // other tools are often PKCS#8 v1, which `maybe_unchecked` accepts by
984    // deriving the public half itself.
985    ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8)
986        .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(pkcs8))
987        .map_err(|_| bad_agent_key("not an Ed25519 PKCS#8 key"))
988}
989
990/// Derive `(publicKeySpki b64u, multikey)` from a keypair.
991fn public_identity_for(pair: &ring::signature::Ed25519KeyPair) -> (String, String) {
992    use ring::signature::KeyPair as _;
993    let mut spki = Vec::with_capacity(44);
994    spki.extend_from_slice(&ED25519_SPKI_PREFIX);
995    spki.extend_from_slice(pair.public_key().as_ref());
996    (
997        URL_SAFE_NO_PAD.encode(&spki),
998        format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&spki))),
999    )
1000}
1001
1002/// Load and validate a signing-key file (agent or brain — same format):
1003/// one base64url line of PKCS#8. Public so `dbmd key rotate` can load the
1004/// old key explicitly.
1005pub fn load_signing_key(path: &Path) -> LinkResult<AgentSigningKey> {
1006    load_agent_key(path)
1007}
1008
1009/// Load and validate the agent key file: one base64url line of PKCS#8.
1010fn load_agent_key(path: &Path) -> LinkResult<AgentSigningKey> {
1011    #[cfg(unix)]
1012    let file = {
1013        use std::os::fd::{AsRawFd as _, FromRawFd as _};
1014        use std::os::unix::ffi::OsStrExt as _;
1015        let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
1016            .map_err(|e| bad_agent_key(&format!("cannot open the key parent: {e}")))?;
1017        let leaf = path
1018            .file_name()
1019            .ok_or_else(|| bad_agent_key("the key path has no file name"))?;
1020        let leaf = c_name(leaf.as_bytes(), &path.display().to_string())?;
1021        let fd = unsafe {
1022            libc::openat(
1023                parent.as_raw_fd(),
1024                leaf.as_ptr(),
1025                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1026            )
1027        };
1028        if fd < 0 {
1029            return Err(bad_agent_key(
1030                "the key path must be an existing regular file without symlink ancestors",
1031            ));
1032        }
1033        unsafe { std::fs::File::from_raw_fd(fd) }
1034    };
1035    #[cfg(not(unix))]
1036    let file = std::fs::File::open(path)
1037        .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
1038    let metadata = file
1039        .metadata()
1040        .map_err(|e| bad_agent_key(&format!("cannot inspect the key file: {e}")))?;
1041    if !metadata.is_file() {
1042        return Err(bad_agent_key("the key path must be a regular file"));
1043    }
1044    #[cfg(unix)]
1045    {
1046        use std::os::unix::fs::PermissionsExt as _;
1047        if metadata.permissions().mode() & 0o077 != 0 {
1048            return Err(bad_agent_key(
1049                "the key file is accessible to group/other; set mode 0600",
1050            ));
1051        }
1052    }
1053    let mut text = String::new();
1054    file.take(1024 * 1024 + 1)
1055        .read_to_string(&mut text)
1056        .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
1057    if text.len() > 1024 * 1024 {
1058        return Err(bad_agent_key("the key file exceeds the size limit"));
1059    }
1060    let pkcs8 = URL_SAFE_NO_PAD
1061        .decode(text.trim())
1062        .map_err(|_| bad_agent_key("the key file is not one base64url line"))?;
1063    let (public_key_spki, multikey) = public_identity_for(&agent_keypair(&pkcs8)?);
1064    Ok(AgentSigningKey {
1065        pkcs8,
1066        multikey,
1067        public_key_spki,
1068    })
1069}
1070
1071/// Durably create a new secret file without an exists→write race and without
1072/// ever exposing a default-mode (commonly 0644) key between write and chmod.
1073/// `create_new` also refuses a planted symlink. The file and its parent
1074/// directory are synced before the caller can publish the corresponding
1075/// public identity remotely.
1076fn write_secret_new(path: &Path, bytes: &[u8]) -> LinkResult<()> {
1077    #[cfg(unix)]
1078    let (mut file, parent, leaf) = {
1079        use std::os::fd::{AsRawFd as _, FromRawFd as _};
1080        use std::os::unix::ffi::OsStrExt as _;
1081        let parent = open_or_create_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))?;
1082        let leaf_name = path
1083            .file_name()
1084            .ok_or_else(|| bad_agent_key("the output key path has no file name"))?;
1085        let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
1086        let fd = unsafe {
1087            libc::openat(
1088                parent.as_raw_fd(),
1089                leaf.as_ptr(),
1090                libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1091                0o600,
1092            )
1093        };
1094        if fd < 0 {
1095            let error = std::io::Error::last_os_error();
1096            if error.kind() == std::io::ErrorKind::AlreadyExists {
1097                return Err(bad_agent_key(
1098                    "the output file already exists — refusing to overwrite a key",
1099                ));
1100            }
1101            return Err(error.into());
1102        }
1103        (unsafe { std::fs::File::from_raw_fd(fd) }, parent, leaf)
1104    };
1105    #[cfg(not(unix))]
1106    let mut file = std::fs::OpenOptions::new()
1107        .write(true)
1108        .create_new(true)
1109        .open(path)
1110        .map_err(|error| {
1111            if error.kind() == std::io::ErrorKind::AlreadyExists {
1112                bad_agent_key("the output file already exists — refusing to overwrite a key")
1113            } else {
1114                LinkError::Io(error)
1115            }
1116        })?;
1117    if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) {
1118        drop(file);
1119        #[cfg(unix)]
1120        let _ =
1121            unsafe { libc::unlinkat(std::os::fd::AsRawFd::as_raw_fd(&parent), leaf.as_ptr(), 0) };
1122        #[cfg(not(unix))]
1123        let _ = std::fs::remove_file(path);
1124        return Err(LinkError::Io(error));
1125    }
1126    drop(file);
1127    #[cfg(unix)]
1128    parent.sync_all()?;
1129    Ok(())
1130}
1131
1132/// What `dbmd key generate` returns: the public identity to register plus
1133/// where the secret landed.
1134#[derive(Debug, Serialize)]
1135pub struct GeneratedAgentKey {
1136    /// `ed25519:<fingerprint>` — the grantable/registerable identity.
1137    pub multikey: String,
1138    /// base64url SPKI DER — what a hub's register endpoint takes.
1139    #[serde(rename = "publicKeySpki")]
1140    pub public_key_spki: String,
1141    /// Where the PKCS#8 secret was written (mode 0600).
1142    #[serde(rename = "keyFile")]
1143    pub key_file: String,
1144}
1145
1146/// Mint a fresh Ed25519 agent keypair. The secret is written to `out`
1147/// (base64url PKCS#8, one line, 0600, refusing to overwrite); only public
1148/// identity is returned. The private key never enters a store and never
1149/// travels — requests carry per-request signatures instead (link.md §8).
1150pub fn generate_agent_key(out: &Path) -> LinkResult<GeneratedAgentKey> {
1151    require_hardened_filesystem("key generation")?;
1152    let rng = ring::rand::SystemRandom::new();
1153    let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
1154        .map_err(|_| bad_agent_key("key generation failed"))?;
1155    let pair = agent_keypair(pkcs8.as_ref())?;
1156    let (spki_b64u, multikey) = public_identity_for(&pair);
1157
1158    write_secret_new(
1159        out,
1160        format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
1161    )?;
1162
1163    Ok(GeneratedAgentKey {
1164        multikey,
1165        public_key_spki: spki_b64u,
1166        key_file: out.display().to_string(),
1167    })
1168}
1169
1170/// Build the origin-bound `LinkMD-Sig` v2 header for one request:
1171/// `canonical = "v2" LF origin LF METHOD LF path+query LF ts LF
1172/// (sha256hex(body) | "-")`.
1173///
1174/// The origin is derived from the already-validated hub URL, never from
1175/// attacker-controlled request metadata. Binding it closes the v1 replay
1176/// class where a proof captured by one hub could be replayed to another hub
1177/// serving the same path inside the timestamp window.
1178fn linkmd_sig_header(
1179    key: &AgentSigningKey,
1180    origin: &str,
1181    method: &str,
1182    path: &str,
1183    body: Option<&str>,
1184) -> LinkResult<String> {
1185    let ts = std::time::SystemTime::now()
1186        .duration_since(std::time::UNIX_EPOCH)
1187        .map_err(|_| bad_agent_key("system clock is before the epoch"))?
1188        .as_secs();
1189    let body_hash = match body {
1190        Some(b) => format!("{:x}", Sha256::digest(b.as_bytes())),
1191        None => "-".to_string(),
1192    };
1193    let canonical = format!(
1194        "v2\n{}\n{}\n{}\n{}\n{}",
1195        origin,
1196        method.to_uppercase(),
1197        path,
1198        ts,
1199        body_hash
1200    );
1201    let pair = agent_keypair(&key.pkcs8)?;
1202    let sig = URL_SAFE_NO_PAD.encode(pair.sign(canonical.as_bytes()).as_ref());
1203    let fingerprint = key.multikey.trim_start_matches("ed25519:");
1204    Ok(format!(
1205        "LinkMD-Sig v2,key=ed25519:{fingerprint},ts={ts},sig={sig}"
1206    ))
1207}
1208
1209// ─────────────────────────────────────────────────────────────────────────────
1210// Self-custody feed entries — the client signs what the hub only verifies
1211// ─────────────────────────────────────────────────────────────────────────────
1212
1213/// One `files` element of a wire-profile-v1 feed entry (SPEC §5.1: fields in
1214/// exactly this order).
1215#[derive(Serialize)]
1216struct WireFeedFile {
1217    path: String,
1218    sha256: String,
1219    bytes: u64,
1220}
1221
1222/// The unsigned entry in the normative §5.1 field order — serde serializes
1223/// struct fields in declaration order, which IS the wire contract.
1224#[derive(Serialize)]
1225struct UnsignedWireEntry<'a> {
1226    v: u8,
1227    seq: u64,
1228    ts: String,
1229    brain: &'a str,
1230    public_key: &'a str,
1231    kind: &'a str,
1232    op: &'a str,
1233    pack_sha256: &'a str,
1234    files: &'a [WireFeedFile],
1235    removed: &'a [String],
1236    prev_entry_hash: Option<&'a str>,
1237}
1238
1239/// Build and sign a wire-profile-v1 `push` feed entry with a self-custodied
1240/// brain key: serialize the unsigned entry compactly in the normative order,
1241/// Ed25519-sign those exact bytes, splice `sig` on as the final field. The
1242/// returned string is the exact serialization the hub stores verbatim (plus
1243/// one trailing newline) and every independent reader re-derives.
1244fn self_custody_entry(
1245    key: &AgentSigningKey,
1246    seq: u64,
1247    ts: String,
1248    pack_sha256: &str,
1249    files: &[WireFeedFile],
1250    prev_entry_hash: Option<&str>,
1251) -> LinkResult<String> {
1252    let removed: [String; 0] = [];
1253    let unsigned = serde_json::to_string(&UnsignedWireEntry {
1254        v: 1,
1255        seq,
1256        ts,
1257        brain: &key.multikey,
1258        public_key: &key.public_key_spki,
1259        kind: "push",
1260        op: "snapshot",
1261        pack_sha256,
1262        files,
1263        removed: &removed,
1264        prev_entry_hash,
1265    })
1266    .expect("serialize feed entry");
1267    let pair = agent_keypair(&key.pkcs8)?;
1268    let sig = URL_SAFE_NO_PAD.encode(pair.sign(unsigned.as_bytes()).as_ref());
1269    Ok(format!(
1270        "{},\"sig\":\"{}\"}}",
1271        &unsigned[..unsigned.len() - 1],
1272        sig
1273    ))
1274}
1275
1276/// An env var, treated as absent when unset or empty (an empty
1277/// `DBMD_HUB_KEY=` falls through rather than becoming an empty credential).
1278fn env_nonempty(name: &str) -> Option<String> {
1279    std::env::var(name).ok().filter(|v| !v.trim().is_empty())
1280}
1281
1282/// Read the `hub = <URL>` line out of a `.dbmd/config` file. The format is
1283/// deliberately minimal: `key = value` lines, `#` comments, unknown keys
1284/// ignored (forward-compatible). A missing or unreadable file is simply "not
1285/// configured here".
1286fn config_file_hub(path: &Path) -> Option<String> {
1287    const MAX_CONFIG_BYTES: u64 = 64 * 1024;
1288    #[cfg(unix)]
1289    let file = {
1290        use std::os::fd::{AsRawFd as _, FromRawFd as _};
1291        use std::os::unix::ffi::OsStrExt as _;
1292        let parent =
1293            open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new("."))).ok()?;
1294        let leaf = c_name(path.file_name()?.as_bytes(), &path.display().to_string()).ok()?;
1295        let fd = unsafe {
1296            libc::openat(
1297                parent.as_raw_fd(),
1298                leaf.as_ptr(),
1299                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1300            )
1301        };
1302        if fd < 0 {
1303            return None;
1304        }
1305        unsafe { std::fs::File::from_raw_fd(fd) }
1306    };
1307    #[cfg(not(unix))]
1308    let file = std::fs::File::open(path).ok()?;
1309    let metadata = file.metadata().ok()?;
1310    if !metadata.is_file() || metadata.len() > MAX_CONFIG_BYTES {
1311        return None;
1312    }
1313    let mut bytes = Vec::with_capacity(metadata.len() as usize);
1314    file.take(MAX_CONFIG_BYTES + 1)
1315        .read_to_end(&mut bytes)
1316        .ok()?;
1317    if bytes.len() as u64 > MAX_CONFIG_BYTES {
1318        return None;
1319    }
1320    let text = String::from_utf8(bytes).ok()?;
1321    for line in text.lines() {
1322        let line = line.trim();
1323        if line.is_empty() || line.starts_with('#') {
1324            continue;
1325        }
1326        if let Some((k, v)) = line.split_once('=') {
1327            if k.trim() == "hub" {
1328                let v = v.trim();
1329                if !v.is_empty() {
1330                    return Some(v.to_string());
1331                }
1332            }
1333        }
1334    }
1335    None
1336}
1337
1338/// The bearer key must never travel in cleartext; only loopback hosts may
1339/// skip TLS (local development against a hub on localhost).
1340fn assert_safe_hub(hub: &str) -> LinkResult<()> {
1341    let parsed = url::Url::parse(hub).map_err(|_| LinkError::UnsafeHub {
1342        hub: hub.to_string(),
1343    })?;
1344    if !(parsed.scheme().eq_ignore_ascii_case("https")
1345        || parsed.scheme().eq_ignore_ascii_case("http"))
1346        || !parsed.username().is_empty()
1347        || parsed.password().is_some()
1348        || (parsed.path() != "/" && !parsed.path().is_empty())
1349        || parsed.query().is_some()
1350        || parsed.fragment().is_some()
1351    {
1352        return Err(LinkError::UnsafeHub {
1353            hub: hub.to_string(),
1354        });
1355    }
1356    let loopback = match parsed.host() {
1357        Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
1358        Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
1359        Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
1360        None => false,
1361    };
1362    if parsed.scheme().eq_ignore_ascii_case("https") || loopback {
1363        Ok(())
1364    } else {
1365        Err(LinkError::UnsafeHub {
1366            hub: hub.to_string(),
1367        })
1368    }
1369}
1370
1371/// Trim paste artifacts and refuse anything outside the printable-ASCII token
1372/// range WITHOUT echoing the key — an HTTP library rejecting a bad header
1373/// value tends to echo the whole header line, credential included, so the
1374/// gate sits here instead.
1375fn clean_key(raw: &str) -> LinkResult<String> {
1376    let k = raw.trim();
1377    if k.is_empty() || k.bytes().any(|b| !(0x21..=0x7e).contains(&b)) {
1378        return Err(LinkError::BadKey);
1379    }
1380    Ok(k.to_string())
1381}
1382
1383// ─────────────────────────────────────────────────────────────────────────────
1384// Transport — one blocking agent, capped reads, the JSON-or-refuse contract
1385// ─────────────────────────────────────────────────────────────────────────────
1386
1387/// One hub response: the status plus the parsed JSON body when there was one.
1388#[derive(Debug)]
1389pub struct HubResponse {
1390    /// The HTTP status code.
1391    pub status: u16,
1392    /// The parsed JSON body, `None` when the body was empty or not JSON.
1393    pub body: Option<Value>,
1394}
1395
1396struct RawHubResponse {
1397    status: u16,
1398    body: Vec<u8>,
1399}
1400
1401/// Whether a request carries the bearer credential.
1402#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1403enum Auth {
1404    /// Send `authorization: Bearer <key>`; error without a key.
1405    Required,
1406    /// Send no credential — the propose door is unauthenticated by design.
1407    None,
1408    /// Send the configured credential when one exists, otherwise nothing —
1409    /// brain-addressed propose works anonymously on public brains, and an
1410    /// authenticated caller earns a bigger actor-class budget.
1411    Optional,
1412}
1413
1414fn agent_builder_with_timeout(overall: std::time::Duration) -> ureq::AgentBuilder {
1415    ureq::AgentBuilder::new()
1416        .user_agent(concat!("dbmd/", env!("CARGO_PKG_VERSION")))
1417        // Never follow a redirect while a bearer, a store pack, or a signed
1418        // response is in flight. Callers see the 3xx as a non-success instead
1419        // of letting an origin steer sensitive material elsewhere.
1420        .redirects(0)
1421        .timeout_connect(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
1422        .timeout_read(std::time::Duration::from_secs(READ_TIMEOUT_SECS))
1423        .timeout_write(overall)
1424        .timeout(overall)
1425}
1426
1427fn hub_agent(cfg: &HubConfig) -> LinkResult<ureq::Agent> {
1428    hub_agent_with_timeout(
1429        cfg,
1430        std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS),
1431    )
1432}
1433
1434fn hub_agent_with_timeout(
1435    cfg: &HubConfig,
1436    overall: std::time::Duration,
1437) -> LinkResult<ureq::Agent> {
1438    if !cfg.store_selected {
1439        return Ok(agent_builder_with_timeout(overall).build());
1440    }
1441    let parsed = url::Url::parse(&cfg.hub).map_err(|_| LinkError::UnsafeHub {
1442        hub: cfg.hub.clone(),
1443    })?;
1444    pinned_public_agent_pooled(
1445        &parsed,
1446        false,
1447        "store-selected hub",
1448        AgentShape {
1449            overall,
1450            ..AgentShape::default()
1451        },
1452    )
1453}
1454
1455/// Perform one hub request. `path` is the binding path (starts with `/`);
1456/// `body` posts JSON. Transport failures, oversized bodies, and non-UTF-8 are
1457/// all surfaced as typed [`LinkError`]s; HTTP error statuses are returned in
1458/// the [`HubResponse`] for [`ensure_ok`] to shape.
1459fn request_raw(
1460    cfg: &HubConfig,
1461    method: &str,
1462    path: &str,
1463    body: Option<&Value>,
1464    auth: Auth,
1465    max_response_bytes: u64,
1466) -> LinkResult<RawHubResponse> {
1467    let http = hub_agent(cfg)?;
1468    request_raw_with_agent(
1469        cfg,
1470        &http,
1471        method,
1472        path,
1473        body,
1474        RawRequestOptions {
1475            auth,
1476            max_response_bytes,
1477            request_id: None,
1478        },
1479    )
1480}
1481
1482struct RawRequestOptions<'a> {
1483    auth: Auth,
1484    max_response_bytes: u64,
1485    request_id: Option<&'a str>,
1486}
1487
1488fn request_raw_with_agent(
1489    cfg: &HubConfig,
1490    http: &ureq::Agent,
1491    method: &str,
1492    path: &str,
1493    body: Option<&Value>,
1494    options: RawRequestOptions<'_>,
1495) -> LinkResult<RawHubResponse> {
1496    let url = format!("{}{}", cfg.hub, path);
1497    let encoded_body = body.map(Value::to_string);
1498    let origin = normalized_origin(&cfg.hub)?;
1499    // An agent signing key outranks the bearer: possession proofs put nothing
1500    // reusable on the wire, so when both are configured the stronger one wins.
1501    let credential = match options.auth {
1502        Auth::Required => Some(match &cfg.agent_key {
1503            Some(key) => linkmd_sig_header(key, &origin, method, path, encoded_body.as_deref())?,
1504            None => format!("Bearer {}", cfg.require_key()?),
1505        }),
1506        Auth::Optional => match &cfg.agent_key {
1507            Some(key) => Some(linkmd_sig_header(
1508                key,
1509                &origin,
1510                method,
1511                path,
1512                encoded_body.as_deref(),
1513            )?),
1514            None => cfg.key.as_deref().map(|k| format!("Bearer {k}")),
1515        },
1516        Auth::None => None,
1517    };
1518    let result = with_connect_retries(|| {
1519        let mut req = http.request(method, &url);
1520        if let Some(value) = &credential {
1521            req = req.set("authorization", value);
1522        }
1523        if let Some(value) = options.request_id {
1524            req = req.set("x-request-id", value);
1525        }
1526        match &encoded_body {
1527            Some(value) => req
1528                .set("content-type", "application/json")
1529                .send_string(value)
1530                .map_err(Box::new),
1531            None => req.call().map_err(Box::new),
1532        }
1533    });
1534    let resp = match result {
1535        Ok(resp) => resp,
1536        Err(error) => match *error {
1537            ureq::Error::Status(_, resp) => resp,
1538            ureq::Error::Transport(error) => {
1539                return Err(LinkError::Transport {
1540                    hub: cfg.hub.clone(),
1541                    message: error.to_string(),
1542                });
1543            }
1544        },
1545    };
1546
1547    let status = resp.status();
1548    let buf = read_response_body(resp, options.max_response_bytes + 1, &cfg.hub)?;
1549    if buf.len() as u64 > options.max_response_bytes {
1550        return Err(LinkError::ResponseTooLarge {
1551            limit_bytes: options.max_response_bytes,
1552        });
1553    }
1554    Ok(RawHubResponse { status, body: buf })
1555}
1556
1557fn request_capped(
1558    cfg: &HubConfig,
1559    method: &str,
1560    path: &str,
1561    body: Option<&Value>,
1562    auth: Auth,
1563    max_response_bytes: u64,
1564) -> LinkResult<HubResponse> {
1565    let raw = request_raw(cfg, method, path, body, auth, max_response_bytes)?;
1566    let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
1567    Ok(HubResponse {
1568        status: raw.status,
1569        body: parsed,
1570    })
1571}
1572
1573/// One request the hub is allowed to take brain-sized time over. Used for the
1574/// commit lane, where abandoning the attempt early does not cancel the work —
1575/// it only loses the receipt for it.
1576///
1577/// Which is why a lost connection is asked again rather than reported. The
1578/// mutation coordinate is derived from the observed base and the exact
1579/// operation set precisely so a repeat is the SAME effect: the hub answers a
1580/// mutation it already applied with that commit's receipt instead of a second
1581/// commit. Measured on a 32,338-file store: the commit landed durably and the
1582/// push reported a transport failure, because nobody went back for the
1583/// receipt.
1584fn request_patient(
1585    cfg: &HubConfig,
1586    method: &str,
1587    path: &str,
1588    body: Option<&Value>,
1589    auth: Auth,
1590) -> LinkResult<HubResponse> {
1591    let http = hub_agent_with_timeout(
1592        cfg,
1593        std::time::Duration::from_secs(COMMIT_REQUEST_TIMEOUT_SECS),
1594    )?;
1595    let mut attempt = 0;
1596    loop {
1597        let sent = request_raw_with_agent(
1598            cfg,
1599            &http,
1600            method,
1601            path,
1602            body,
1603            RawRequestOptions {
1604                auth,
1605                max_response_bytes: MAX_RESPONSE_BYTES,
1606                request_id: None,
1607            },
1608        );
1609        match sent {
1610            Err(LinkError::Transport { .. }) if attempt + 1 < COMMIT_ATTEMPTS => {
1611                std::thread::sleep(std::time::Duration::from_millis(
1612                    COMMIT_RETRY_BACKOFF_MS[attempt.min(COMMIT_RETRY_BACKOFF_MS.len() - 1)],
1613                ));
1614                attempt += 1;
1615            }
1616            Err(error) => return Err(error),
1617            Ok(raw) => {
1618                return Ok(HubResponse {
1619                    status: raw.status,
1620                    body: serde_json::from_slice(&raw.body).ok(),
1621                })
1622            }
1623        }
1624    }
1625}
1626
1627fn request(
1628    cfg: &HubConfig,
1629    method: &str,
1630    path: &str,
1631    body: Option<&Value>,
1632    auth: Auth,
1633) -> LinkResult<HubResponse> {
1634    request_capped(cfg, method, path, body, auth, MAX_RESPONSE_BYTES)
1635}
1636
1637/// Repeat one challenge-response mutation with the exact request identity the
1638/// hub bound into its actor claim. A new request id would create a new actor
1639/// hash and therefore a different canonical commit candidate, making the
1640/// otherwise correct self-custody signature unusable.
1641fn request_with_request_id(
1642    cfg: &HubConfig,
1643    method: &str,
1644    path: &str,
1645    body: Option<&Value>,
1646    auth: Auth,
1647    request_id: &str,
1648) -> LinkResult<HubResponse> {
1649    if request_id.is_empty()
1650        || request_id.len() > 128
1651        || !request_id
1652            .bytes()
1653            .all(|byte| byte.is_ascii_alphanumeric() || b"-_.:".contains(&byte))
1654    {
1655        return Err(invalid_feed("hub returned an unsafe request id"));
1656    }
1657    // Both callers are commit responses, so this shares the commit lane's
1658    // patience: the hub is finishing the same brain-sized work either way.
1659    let http = hub_agent_with_timeout(
1660        cfg,
1661        std::time::Duration::from_secs(COMMIT_REQUEST_TIMEOUT_SECS),
1662    )?;
1663    let raw = request_raw_with_agent(
1664        cfg,
1665        &http,
1666        method,
1667        path,
1668        body,
1669        RawRequestOptions {
1670            auth,
1671            max_response_bytes: MAX_RESPONSE_BYTES,
1672            request_id: Some(request_id),
1673        },
1674    )?;
1675    Ok(HubResponse {
1676        status: raw.status,
1677        body: serde_json::from_slice(&raw.body).ok(),
1678    })
1679}
1680
1681fn ensure_raw_ok(r: RawHubResponse, what: &'static str) -> LinkResult<Vec<u8>> {
1682    if (200..300).contains(&r.status) {
1683        return Ok(r.body);
1684    }
1685    ensure_ok(
1686        HubResponse {
1687            status: r.status,
1688            body: serde_json::from_slice(&r.body).ok(),
1689        },
1690        what,
1691    )
1692    .and_then(|_| Err(invalid_feed("a non-2xx response was accepted unexpectedly")))
1693}
1694
1695/// These failures happen before any HTTP request reaches the hub, so retrying
1696/// cannot duplicate a mutation. Mid-stream I/O is deliberately excluded: once
1697/// bytes may have crossed the wire, the caller must rely on the verb's own
1698/// idempotency contract instead of guessing.
1699fn is_pre_request_transport(kind: ureq::ErrorKind) -> bool {
1700    matches!(
1701        kind,
1702        ureq::ErrorKind::Dns | ureq::ErrorKind::ConnectionFailed | ureq::ErrorKind::ProxyConnect
1703    )
1704}
1705
1706fn with_connect_retries(
1707    mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
1708) -> Result<ureq::Response, Box<ureq::Error>> {
1709    let mut attempt = 0;
1710    loop {
1711        match send() {
1712            Err(error)
1713                if matches!(
1714                    error.as_ref(),
1715                    ureq::Error::Transport(transport)
1716                        if is_pre_request_transport(transport.kind())
1717                ) && attempt + 1 < CONNECT_ATTEMPTS =>
1718            {
1719                std::thread::sleep(std::time::Duration::from_millis(
1720                    CONNECT_RETRY_BACKOFF_MS[attempt],
1721                ));
1722                attempt += 1;
1723            }
1724            result => return result,
1725        }
1726    }
1727}
1728
1729fn hub_is_loopback(hub: &str) -> bool {
1730    url::Url::parse(hub).ok().is_some_and(|parsed| {
1731        parsed.host().is_some_and(|host| match host {
1732            url::Host::Domain(host) => host.eq_ignore_ascii_case("localhost"),
1733            url::Host::Ipv4(ip) => ip.is_loopback(),
1734            url::Host::Ipv6(ip) => ip.is_loopback(),
1735        })
1736    })
1737}
1738
1739/// Every safety property a presigned object-store URL must carry, checked
1740/// before anything is sent to it. Split out from agent construction so a batch
1741/// can share one connection pool without ever skipping a per-URL check.
1742fn checked_presigned_url(cfg: &HubConfig, raw: &str) -> LinkResult<(url::Url, bool)> {
1743    let parsed = url::Url::parse(raw).map_err(|_| LinkError::InvalidPack {
1744        message: "the hub returned an invalid object-store URL".to_string(),
1745    })?;
1746    let allow_private = hub_is_loopback(&cfg.hub)
1747        || env_nonempty(ALLOW_PRIVATE_OBJECT_URL_ENV).as_deref() == Some("1");
1748    if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1749        || !parsed.username().is_empty()
1750        || parsed.password().is_some()
1751        || parsed.fragment().is_some()
1752    {
1753        return Err(LinkError::InvalidPack {
1754            message: "the hub returned an unsafe object-store URL".to_string(),
1755        });
1756    }
1757    Ok((parsed, allow_private))
1758}
1759
1760fn presigned_agent(cfg: &HubConfig, raw: &str) -> LinkResult<ureq::Agent> {
1761    let (parsed, allow_private) = checked_presigned_url(cfg, raw)?;
1762    pinned_public_agent(&parsed, allow_private, "object-store URL").map_err(|_| {
1763        LinkError::InvalidPack {
1764            message: "the hub returned an object-store URL with an unsafe network target"
1765                .to_string(),
1766        }
1767    })
1768}
1769
1770/// One pinned agent for a whole staging batch.
1771///
1772/// Every object in a batch is presigned against the same object-store
1773/// authority, so one agent can serve them all — and must: building one per
1774/// object costs a DNS resolution and a fresh TLS handshake per object, which is
1775/// the difference between a real store staging in minutes and in an hour. The
1776/// shared authority is verified rather than assumed, and a batch that ever
1777/// spans two of them falls back to a per-object agent.
1778fn shared_staging_agent(cfg: &HubConfig, urls: &[&str]) -> Option<ureq::Agent> {
1779    let (first, allow_private) = checked_presigned_url(cfg, urls.first()?).ok()?;
1780    let authority = (
1781        first.host_str()?.to_string(),
1782        first.port_or_known_default()?,
1783    );
1784    for raw in &urls[1..] {
1785        let (parsed, _) = checked_presigned_url(cfg, raw).ok()?;
1786        if (parsed.host_str()?, parsed.port_or_known_default()?)
1787            != (authority.0.as_str(), authority.1)
1788        {
1789            return None;
1790        }
1791    }
1792    pinned_public_agent_pooled(
1793        &first,
1794        allow_private,
1795        "object-store URL",
1796        AgentShape {
1797            idle_per_host: V2_UPLOAD_CONCURRENCY,
1798            ..AgentShape::default()
1799        },
1800    )
1801    .ok()
1802}
1803
1804fn put_presigned(cfg: &HubConfig, raw: &str, headers: &Value, bytes: &[u8]) -> LinkResult<()> {
1805    let http = presigned_agent(cfg, raw)?;
1806    let mut attempt = 0;
1807    let result = loop {
1808        // The reservation states content-length itself. This client sends the
1809        // body from memory, so it never needs to add one, and adding one under
1810        // a different capitalization would leave both on the wire.
1811        let mut req = http.put(raw);
1812        if let Some(map) = headers.as_object() {
1813            for (name, value) in map {
1814                if let Some(value) = value.as_str() {
1815                    req = req.set(name, value);
1816                }
1817            }
1818        }
1819        match req.send_bytes(bytes) {
1820            // An upload is idempotent here: fixed content address, exact
1821            // length and checksum, write-once precondition. A dropped
1822            // connection or a single overloaded refusal is worth replaying
1823            // rather than failing a whole migration on. 412 already means
1824            // "someone else stored these exact bytes".
1825            Err(ureq::Error::Transport(_)) if attempt + 1 < UPLOAD_ATTEMPTS => {
1826                std::thread::sleep(std::time::Duration::from_millis(upload_retry_backoff_ms(
1827                    attempt,
1828                )));
1829                attempt += 1;
1830            }
1831            Err(ureq::Error::Status(status, _))
1832                if status != 412
1833                    && is_retryable_upload_status(status)
1834                    && attempt + 1 < UPLOAD_ATTEMPTS =>
1835            {
1836                std::thread::sleep(std::time::Duration::from_millis(upload_retry_backoff_ms(
1837                    attempt,
1838                )));
1839                attempt += 1;
1840            }
1841            result => break result,
1842        }
1843    };
1844    match result {
1845        Ok(resp) if (200..300).contains(&resp.status()) => {
1846            drain_presigned_response(resp);
1847            Ok(())
1848        }
1849        Ok(resp) => Err(presigned_upload_refusal(resp)),
1850        Err(error) => match error {
1851            // Immutable uploads use If-None-Match. A concurrent writer may
1852            // win the same content address; the commit/confirm endpoint reads
1853            // and hashes that object before accepting it, so 412 safely means
1854            // "continue to verification", never "trust the upload".
1855            ureq::Error::Status(412, _) => Ok(()),
1856            ureq::Error::Status(_, resp) => Err(presigned_upload_refusal(resp)),
1857            ureq::Error::Transport(err) => Err(LinkError::Transport {
1858                hub: "the object store".to_string(),
1859                message: err.to_string(),
1860            }),
1861        },
1862    }
1863}
1864
1865/// Read a response body, typed as the transport failure it is.
1866///
1867/// A connection that fails while the body is still arriving is the same class
1868/// of problem as one that fails before it arrives at all — but a bare `?` on
1869/// the read turned it into an anonymous io error that no retry path recognised
1870/// and no message attributed to a peer. On a migration making tens of
1871/// thousands of requests, one stalled body ended the whole push and reported
1872/// only "timed out reading response".
1873fn read_response_body(response: ureq::Response, limit: u64, peer: &str) -> LinkResult<Vec<u8>> {
1874    let mut buf = Vec::new();
1875    response
1876        .into_reader()
1877        .take(limit)
1878        .read_to_end(&mut buf)
1879        .map_err(|error| LinkError::Transport {
1880            hub: peer.to_string(),
1881            message: error.to_string(),
1882        })?;
1883    Ok(buf)
1884}
1885
1886/// Draining a response is what returns its connection to the pool: the client
1887/// cannot recycle a socket whose body may still have bytes pending, so a body
1888/// that is dropped instead of read costs the next object a fresh handshake.
1889/// An object-store PUT answers with almost nothing, so this is nearly free.
1890fn drain_presigned_response(response: ureq::Response) {
1891    let mut reader = response.into_reader().take(64 * 1024);
1892    let _ = std::io::copy(&mut reader, &mut std::io::sink());
1893}
1894
1895/// Carry the object store's own reason for a refusal. An unexplained status is
1896/// unactionable; the body states checksum mismatch, precondition, or expiry.
1897fn presigned_upload_refusal(response: ureq::Response) -> LinkError {
1898    let status = response.status();
1899    let detail = response
1900        .into_string()
1901        .ok()
1902        .map(|body| body.chars().take(400).collect::<String>())
1903        .filter(|body| !body.trim().is_empty());
1904    LinkError::Http {
1905        what: "pack upload",
1906        status,
1907        message: match detail {
1908            Some(body) => format!(
1909                "object store rejected the upload: {}",
1910                body.replace('\n', " ")
1911            ),
1912            None => "object store rejected the upload".to_string(),
1913        },
1914        code: None,
1915        details: None,
1916    }
1917}
1918
1919fn one_past_bounded_limit(max_bytes: u64) -> Option<u64> {
1920    max_bytes.checked_add(1)
1921}
1922
1923fn presigned_download_read_limit() -> u64 {
1924    one_past_bounded_limit(MAX_PACK_BYTES)
1925        .expect("the fixed presigned-download ceiling must leave room for the refusal byte")
1926}
1927
1928fn get_presigned(cfg: &HubConfig, raw: &str) -> LinkResult<Vec<u8>> {
1929    let http = presigned_agent(cfg, raw)?;
1930    let resp = match with_connect_retries(|| http.get(raw).call().map_err(Box::new)) {
1931        Ok(resp) => resp,
1932        Err(error) => match *error {
1933            ureq::Error::Status(_, resp) => {
1934                return Err(LinkError::Http {
1935                    what: "pack download",
1936                    status: resp.status(),
1937                    message: "object store rejected the download".to_string(),
1938                    code: None,
1939                    details: None,
1940                });
1941            }
1942            ureq::Error::Transport(err) => {
1943                return Err(LinkError::Transport {
1944                    hub: "the object store".to_string(),
1945                    message: err.to_string(),
1946                });
1947            }
1948        },
1949    };
1950    if !(200..300).contains(&resp.status()) {
1951        return Err(LinkError::Http {
1952            what: "pack download",
1953            status: resp.status(),
1954            message: "object store rejected the download".to_string(),
1955            code: None,
1956            details: None,
1957        });
1958    }
1959    let bytes = read_response_body(resp, presigned_download_read_limit(), "the object store")?;
1960    if bytes.len() as u64 > MAX_PACK_BYTES {
1961        return Err(LinkError::InvalidPack {
1962            message: "download exceeds the compressed-size limit".to_string(),
1963        });
1964    }
1965    Ok(bytes)
1966}
1967
1968/// Unwrap a successful JSON body, or shape the failure: a >=400 surfaces the
1969/// hub's own `error` + `code`; a 2xx without JSON is refused as not a hub
1970/// answer.
1971fn ensure_ok(r: HubResponse, what: &'static str) -> LinkResult<Value> {
1972    if !(200..300).contains(&r.status) {
1973        let message = r
1974            .body
1975            .as_ref()
1976            .and_then(|b| b.get("error"))
1977            .and_then(Value::as_str)
1978            .unwrap_or("unknown error")
1979            .to_string();
1980        let code = r
1981            .body
1982            .as_ref()
1983            .and_then(|b| b.get("code"))
1984            .and_then(Value::as_str)
1985            .map(str::to_string);
1986        let details = r.body.as_ref().and_then(|b| b.get("details")).cloned();
1987        return Err(LinkError::Http {
1988            what,
1989            status: r.status,
1990            message,
1991            code,
1992            details,
1993        });
1994    }
1995    r.body.ok_or(LinkError::NotJson {
1996        what,
1997        status: r.status,
1998    })
1999}
2000
2001// ─────────────────────────────────────────────────────────────────────────────
2002// resolve — handle → brain card; @brain/id → the record
2003// ─────────────────────────────────────────────────────────────────────────────
2004
2005/// Resolve an address. A bare `@brain` returns the brain card (metadata +
2006/// index stats — the v0 form of the card; keys arrive with the protocol's
2007/// signing layer). `@brain/<id>` and `@brain/<path>.md` return the full
2008/// record, frontmatter + body.
2009fn is_public_registry_ip(ip: std::net::IpAddr) -> bool {
2010    match ip {
2011        std::net::IpAddr::V4(ip) => {
2012            let [a, b, c, _] = ip.octets();
2013            !(a == 0
2014                || a == 10
2015                || a == 127
2016                || (a == 100 && (64..=127).contains(&b))
2017                || (a == 169 && b == 254)
2018                || (a == 172 && (16..=31).contains(&b))
2019                || (a == 192 && b == 0 && c == 0)
2020                || (a == 192 && b == 0 && c == 2)
2021                || (a == 192 && b == 88 && c == 99)
2022                || (a == 192 && b == 168)
2023                || (a == 198 && (b == 18 || b == 19))
2024                || (a == 198 && b == 51 && c == 100)
2025                || (a == 203 && b == 0 && c == 113)
2026                || a >= 224)
2027        }
2028        std::net::IpAddr::V6(ip) => {
2029            let segments = ip.segments();
2030            // Conservatively accept only global unicast 2000::/3, excluding
2031            // special-purpose/transition blocks. In particular, 6to4 embeds
2032            // an IPv4 destination and must not tunnel a validated public
2033            // connect to 127/8 or RFC1918.
2034            (segments[0] & 0xe000) == 0x2000
2035                && !(segments[0] == 0x2001 && (segments[1] & 0xfe00) == 0)
2036                && !(segments[0] == 0x2001 && segments[1] == 0x0db8)
2037                && segments[0] != 0x2002
2038                && !(segments[0] == 0x3fff && (segments[1] & 0xf000) == 0)
2039        }
2040    }
2041}
2042
2043#[derive(Clone)]
2044struct PinnedRegistryResolver {
2045    netloc: String,
2046    addresses: Vec<std::net::SocketAddr>,
2047}
2048
2049impl ureq::Resolver for PinnedRegistryResolver {
2050    fn resolve(&self, requested: &str) -> std::io::Result<Vec<std::net::SocketAddr>> {
2051        if requested == self.netloc {
2052            Ok(self.addresses.clone())
2053        } else {
2054            Err(std::io::Error::new(
2055                std::io::ErrorKind::PermissionDenied,
2056                "registry request attempted to resolve an unvalidated authority",
2057            ))
2058        }
2059    }
2060}
2061
2062fn pinned_public_agent(
2063    url: &url::Url,
2064    allow_private: bool,
2065    label: &str,
2066) -> LinkResult<ureq::Agent> {
2067    pinned_public_agent_pooled(url, allow_private, label, AgentShape::default())
2068}
2069
2070/// How one agent is built: `idle_per_host` sizes the connection pool (the
2071/// default of one is right for a lone request but throttles a concurrent batch
2072/// back to a single warm connection), and `overall` is the wall-clock budget
2073/// for one attempt.
2074struct AgentShape {
2075    idle_per_host: usize,
2076    overall: std::time::Duration,
2077}
2078
2079impl Default for AgentShape {
2080    fn default() -> Self {
2081        Self {
2082            idle_per_host: 1,
2083            overall: std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS),
2084        }
2085    }
2086}
2087
2088fn pinned_public_agent_pooled(
2089    url: &url::Url,
2090    allow_private: bool,
2091    label: &str,
2092    shape: AgentShape,
2093) -> LinkResult<ureq::Agent> {
2094    let host = url
2095        .host_str()
2096        .ok_or_else(|| invalid_feed(format!("{label} has no host")))?;
2097    let port = url
2098        .port_or_known_default()
2099        .ok_or_else(|| invalid_feed(format!("{label} has no port")))?;
2100    let addresses = resolve_addresses_with_deadline(
2101        host,
2102        port,
2103        std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS),
2104    )
2105    .map_err(|error| invalid_feed(format!("{label} DNS resolution failed: {error}")))?;
2106    if addresses.is_empty() {
2107        return Err(invalid_feed(format!("{label} DNS returned no addresses")));
2108    }
2109    if !allow_private
2110        && addresses
2111            .iter()
2112            .any(|address| !is_public_registry_ip(address.ip()))
2113    {
2114        return Err(invalid_feed(format!(
2115            "{label} resolves to a non-public address"
2116        )));
2117    }
2118    let netloc = if host.contains(':') {
2119        format!("[{host}]:{port}")
2120    } else {
2121        format!("{host}:{port}")
2122    };
2123    Ok(agent_builder_with_timeout(shape.overall)
2124        .max_idle_connections_per_host(shape.idle_per_host.max(1))
2125        .resolver(PinnedRegistryResolver { netloc, addresses })
2126        .build())
2127}
2128
2129/// Resolve one authority without allowing libc DNS to hold the caller forever.
2130/// `ToSocketAddrs` itself has no timeout API, so resolution runs in a detached
2131/// worker and only its result channel is awaited. A late resolver result has no
2132/// side effects and is discarded after the deadline.
2133fn resolve_addresses_with_deadline(
2134    host: &str,
2135    port: u16,
2136    timeout: std::time::Duration,
2137) -> std::io::Result<Vec<std::net::SocketAddr>> {
2138    use std::net::ToSocketAddrs as _;
2139
2140    let host = host.to_string();
2141    let (send, receive) = std::sync::mpsc::sync_channel(1);
2142    std::thread::Builder::new()
2143        .name("dbmd-dns".to_string())
2144        .spawn(move || {
2145            let result = (host.as_str(), port)
2146                .to_socket_addrs()
2147                .map(|addresses| addresses.collect());
2148            let _ = send.send(result);
2149        })
2150        .map_err(|error| std::io::Error::other(format!("cannot start resolver: {error}")))?;
2151    match receive.recv_timeout(timeout) {
2152        Ok(result) => result,
2153        Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(std::io::Error::new(
2154            std::io::ErrorKind::TimedOut,
2155            "resolution exceeded its deadline",
2156        )),
2157        Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(std::io::Error::other(
2158            "resolver stopped without returning a result",
2159        )),
2160    }
2161}
2162
2163fn registry_agent(url: &url::Url) -> LinkResult<ureq::Agent> {
2164    let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
2165    pinned_public_agent(url, allow_private, "registry home")
2166}
2167
2168/// GET an absolute URL as JSON with NO credential — used to fetch a brain card
2169/// from a FOREIGN home during registry resolution. DNS is resolved once,
2170/// every answer is required to be public, and that exact answer set is pinned
2171/// into a no-redirect HTTP agent to close private-network SSRF and rebinding.
2172fn get_json_absolute(url: &str) -> LinkResult<Value> {
2173    let parsed = url::Url::parse(url).map_err(|_| invalid_feed("invalid registry home URL"))?;
2174    let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
2175    if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
2176        || !parsed.username().is_empty()
2177        || parsed.password().is_some()
2178        || parsed.query().is_some()
2179        || parsed.fragment().is_some()
2180    {
2181        return Err(invalid_feed("unsafe registry home URL"));
2182    }
2183    let http = registry_agent(&parsed)?;
2184    let resp = match with_connect_retries(|| http.get(url).call().map_err(Box::new)) {
2185        Ok(resp) => resp,
2186        Err(error) => match *error {
2187            ureq::Error::Status(status, resp) => {
2188                let _ = resp;
2189                return Err(LinkError::Http {
2190                    what: "registry home fetch",
2191                    status,
2192                    message: "the home node rejected the card request".to_string(),
2193                    code: None,
2194                    details: None,
2195                });
2196            }
2197            ureq::Error::Transport(err) => {
2198                return Err(LinkError::Transport {
2199                    hub: url.to_string(),
2200                    message: err.to_string(),
2201                });
2202            }
2203        },
2204    };
2205    if !(200..300).contains(&resp.status()) {
2206        return Err(LinkError::Http {
2207            what: "registry home fetch",
2208            status: resp.status(),
2209            message: "the home node returned a redirect or error".to_string(),
2210            code: None,
2211            details: None,
2212        });
2213    }
2214    let buf = read_response_body(resp, MAX_REGISTRY_CARD_BYTES + 1, url)?;
2215    if buf.len() as u64 > MAX_REGISTRY_CARD_BYTES {
2216        return Err(LinkError::ResponseTooLarge {
2217            limit_bytes: MAX_REGISTRY_CARD_BYTES,
2218        });
2219    }
2220    serde_json::from_slice(&buf).map_err(|_| LinkError::InvalidFeed {
2221        message: "the home node returned invalid JSON".to_string(),
2222    })
2223}
2224
2225/// Resolve a bare `@handle` through the federation registry (link.md §7.1,
2226/// E5): look the handle up in the hub's registry, fetch the brain card from
2227/// the returned HOME node, and PIN — the card's identity fingerprint must
2228/// equal the registry's, or resolution fails. Returns the card enriched with
2229/// the resolved `home`, or `Ok(None)` when the registry has no such handle
2230/// (so the caller can fall back to a direct lookup).
2231pub fn resolve_registry(cfg: &HubConfig, handle: &str) -> LinkResult<Option<Value>> {
2232    require_safe_ref(handle)?;
2233    // Hold the validated trust-directory capability before any network I/O.
2234    // Every lock/load/save below remains relative to this exact inode even if
2235    // an attacker swaps an ancestor while the registry or home is answering.
2236    let trust_directory = open_trust_dir(cfg)?;
2237    let reg = request_capped(
2238        cfg,
2239        "GET",
2240        &format!("/api/hub/registry/{handle}"),
2241        None,
2242        Auth::None,
2243        MAX_REGISTRY_CARD_BYTES,
2244    )?;
2245    if reg.status == 404 {
2246        return Ok(None);
2247    }
2248    let body = ensure_ok(reg, "registry resolve")?;
2249    let home = body
2250        .get("home")
2251        .and_then(Value::as_str)
2252        .ok_or_else(|| invalid_feed("registry entry has no home"))?;
2253    let brain = body
2254        .get("brain")
2255        .and_then(Value::as_str)
2256        .ok_or_else(|| invalid_feed("registry entry has no brain"))?;
2257    if !crate::ulid::is_ulid(brain) {
2258        return Err(invalid_feed(
2259            "registry entry brain is not a canonical lowercase ULID",
2260        ));
2261    }
2262    let want_fp = body
2263        .get("identity")
2264        .and_then(|i| i.get("fingerprint"))
2265        .and_then(Value::as_str)
2266        .ok_or_else(|| invalid_feed("registry entry has no identity fingerprint"))?;
2267
2268    let home = home.trim_end_matches('/');
2269    let origin = normalized_origin(home)?;
2270    if origin != home {
2271        return Err(invalid_feed(
2272            "registry home must be an origin without a path, query, or fragment",
2273        ));
2274    }
2275    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[handle, brain])?;
2276    let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, handle, brain)?;
2277    if let Some(binding) = &alias_binding {
2278        if binding
2279            .home
2280            .as_deref()
2281            .is_some_and(|pinned_home| pinned_home != home)
2282        {
2283            return Err(invalid_feed(
2284                "registry relocated a pinned handle to a different home",
2285            ));
2286        }
2287    }
2288    let card = get_json_absolute(&format!("{home}/api/hub/brains/{brain}"))?;
2289    if card.get("id").and_then(Value::as_str) != Some(brain) {
2290        return Err(invalid_feed(
2291            "the home node served a card for a different brain",
2292        ));
2293    }
2294    let identity: FeedIdentity = serde_json::from_value(
2295        card.get("identity")
2296            .cloned()
2297            .ok_or_else(|| invalid_feed("the home node served no identity"))?,
2298    )
2299    .map_err(|_| invalid_feed("the home node served an invalid identity"))?;
2300    let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
2301    let got_fp = card
2302        .get("identity")
2303        .and_then(|i| i.get("fingerprint"))
2304        .and_then(Value::as_str)
2305        .unwrap_or_default();
2306    if got_fp != want_fp {
2307        return Err(invalid_feed(
2308            "the home node served an identity that does not match the registry — refusing",
2309        ));
2310    }
2311    let current = format!("ed25519:{}", identity.fingerprint);
2312    let advertised_seq = card
2313        .get("headSeq")
2314        .and_then(Value::as_u64)
2315        .ok_or_else(|| invalid_feed("the home node served an invalid head sequence"))?;
2316    let advertised_hash = card.get("feedHash").and_then(Value::as_str);
2317    if (advertised_seq == 0 && !card.get("feedHash").is_some_and(Value::is_null))
2318        || (advertised_seq > 0 && advertised_hash.is_none_or(|hash| !is_sha256(hash)))
2319    {
2320        return Err(invalid_feed(
2321            "the home node served an invalid feed head boundary",
2322        ));
2323    }
2324    // This is required even on first contact. Otherwise a syntactically valid
2325    // identity may claim that a rotation happened after a head the home has
2326    // never reached, then become the permanent TOFU anchor.
2327    verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], advertised_seq)?;
2328    let registry_alias = AliasBinding {
2329        v: 1,
2330        origin: normalized_origin(&cfg.hub)?,
2331        requested: handle.to_string(),
2332        brain: brain.to_string(),
2333        home: Some(home.to_string()),
2334    };
2335    save_canonical_pin_and_alias(
2336        cfg,
2337        &trust_directory,
2338        handle,
2339        brain,
2340        TrustState {
2341            v: 2,
2342            origin: normalized_origin(&cfg.hub)?,
2343            requested: brain.to_string(),
2344            brain: brain.to_string(),
2345            home: None,
2346            anchor,
2347            current,
2348            head_seq: pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq),
2349            feed_hash: pinned
2350                .as_ref()
2351                .and_then(|checkpoint| checkpoint.feed_hash.clone()),
2352            rotations: identity.rotations.clone(),
2353            hub_signer: None,
2354            protocol_profile: None,
2355        },
2356        Some(&registry_alias),
2357    )?;
2358    let mut out = card;
2359    if let Value::Object(map) = &mut out {
2360        map.insert("home".to_string(), Value::String(home.to_string()));
2361        map.insert(
2362            "resolvedVia".to_string(),
2363            Value::String("registry".to_string()),
2364        );
2365    }
2366    Ok(Some(out))
2367}
2368
2369pub fn resolve(cfg: &HubConfig, addr: &Address) -> LinkResult<Value> {
2370    // `Address::parse` refuses these shapes already, but `Address` has public
2371    // fields — re-assert at the wire so a hand-built address can never
2372    // reshape the request path.
2373    require_safe_ref(&addr.brain)?;
2374    if let Some(target) = &addr.target {
2375        let (given, ok) = match target {
2376            AddressTarget::Id(id) => (id, crate::ulid::is_ulid(id)),
2377            AddressTarget::Path(p) => (p, safe_store_rel_path(p) && p.ends_with(".md")),
2378        };
2379        if !ok {
2380            return Err(LinkError::BadAddress {
2381                given: given.clone(),
2382                reason: BAD_TARGET_REASON.to_string(),
2383            });
2384        }
2385    }
2386
2387    // A record is never accepted from the hub's mutable query/index response.
2388    // Under v2, an id lookup may suggest a path, but the client accepts only
2389    // bytes whose exact path proof reaches the verified content root and whose
2390    // parsed id still matches. Under v1, the signed snapshot pack remains the
2391    // binding. Both profiles therefore reject a forged query response.
2392    if let Some(target) = &addr.target {
2393        if let Some(head) = v2_verified_head(cfg, &addr.brain)? {
2394            let pointer = head.pointer.as_ref().ok_or_else(|| LinkError::Http {
2395                what: "resolve",
2396                status: 404,
2397                message: "record not found".to_string(),
2398                code: Some("NOT_FOUND".to_string()),
2399                details: None,
2400            })?;
2401            let (path, file) = match target {
2402                AddressTarget::Path(path) => {
2403                    let file =
2404                        v2_manifest_file(cfg, &head.brain_id, pointer, path)?.ok_or_else(|| {
2405                            LinkError::Http {
2406                                what: "resolve",
2407                                status: 404,
2408                                message: "record not found".to_string(),
2409                                code: Some("NOT_FOUND".to_string()),
2410                                details: None,
2411                            }
2412                        })?;
2413                    (path.clone(), file)
2414                }
2415                AddressTarget::Id(id) => v2_manifest_file_by_id(cfg, &head.brain_id, pointer, id)?,
2416            };
2417            let mut downloaded =
2418                download_v2_blobs(cfg, &head.brain_id, pointer, vec![(&path, &file)])?;
2419            let (_, bytes) = downloaded
2420                .pop()
2421                .ok_or_else(|| invalid_feed("v2 record download returned no bytes"))?;
2422            let resolved = resolve_from_verified_record_bytes(&head.brain_id, target, path, bytes)?;
2423            accept_v2_head(cfg, &head)?;
2424            return Ok(resolved);
2425        }
2426        let remote = verified_remote_head(cfg, &addr.brain, false)?;
2427        if !remote.head.verified {
2428            return Err(invalid_feed(
2429                "a path-scoped feed cannot prove a record against the full signed snapshot",
2430            ));
2431        }
2432        if remote.head.seq == 0 {
2433            return Err(LinkError::Http {
2434                what: "resolve",
2435                status: 404,
2436                message: "record not found".to_string(),
2437                code: Some("NOT_FOUND".to_string()),
2438                details: None,
2439            });
2440        }
2441        let brain = remote.head.brain.clone();
2442        let pack = download_verified_snapshot_pack(cfg, &brain, &remote)?;
2443        return resolve_from_verified_pack(&brain, target, pack);
2444    }
2445
2446    let path = format!("/api/hub/brains/{}", addr.brain);
2447    // Direct first: the caller's own slug and hub-hosted public handles resolve
2448    // here unchanged. Only a bare `@handle` the hub can't resolve directly
2449    // (404) falls through to the federation registry — how a handle reaches a
2450    // brain on ANOTHER node (link.md §7.1, E5).
2451    let direct = request(cfg, "GET", &path, None, Auth::Required)?;
2452    if direct.status == 404 && addr.target.is_none() && !crate::ulid::is_ulid(&addr.brain) {
2453        if let Some(card) = resolve_registry(cfg, &addr.brain)? {
2454            return Ok(card);
2455        }
2456    }
2457    let mut resolved = ensure_ok(direct, "resolve")?;
2458    if resolved.get("storageProfile").and_then(Value::as_str) == Some("v2") {
2459        let v2 = v2_verified_head(cfg, &addr.brain)?
2460            .ok_or_else(|| invalid_feed("v2 resolve card has no verified v2 head"))?;
2461        if resolved.get("id").and_then(Value::as_str) != Some(v2.brain_id.as_str()) {
2462            return Err(invalid_feed(
2463                "resolve card is not bound to the verified v2 brain",
2464            ));
2465        }
2466        let card_identity: FeedIdentity = serde_json::from_value(
2467            resolved
2468                .get("identity")
2469                .cloned()
2470                .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2471        )
2472        .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2473        if card_identity != v2_identity(&v2.identity) {
2474            return Err(invalid_feed(
2475                "resolve card identity differs from the verified v2 identity",
2476            ));
2477        }
2478        accept_v2_head(cfg, &v2)?;
2479        if let Value::Object(card) = &mut resolved {
2480            card.insert(
2481                "headSeq".to_string(),
2482                json!(v2.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
2483            );
2484            card.insert(
2485                "feedHash".to_string(),
2486                v2.pointer
2487                    .as_ref()
2488                    .map(|pointer| Value::String(pointer.feed_hash.clone()))
2489                    .unwrap_or(Value::Null),
2490            );
2491            card.insert(
2492                "storageProfile".to_string(),
2493                Value::String("v2".to_string()),
2494            );
2495            if let Some(pointer) = &v2.pointer {
2496                card.insert(
2497                    "updatedAt".to_string(),
2498                    Value::String(pointer.signed_at.clone()),
2499                );
2500            }
2501        }
2502        return Ok(resolved);
2503    }
2504    // A successful direct response is accepted only after the same centralized
2505    // identity/rotation/feed checkpoint verification used by sync and
2506    // subscribe. No verb gets a weaker ad-hoc pinning path.
2507    let remote = verified_remote_head(cfg, &addr.brain, false)?;
2508    if resolved.get("id").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2509        || resolved.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2510        || resolved.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
2511    {
2512        return Err(invalid_feed(
2513            "resolve card is not bound to the exact verified feed checkpoint",
2514        ));
2515    }
2516    let card_identity: FeedIdentity = serde_json::from_value(
2517        resolved
2518            .get("identity")
2519            .cloned()
2520            .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2521    )
2522    .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2523    if remote.identity.as_ref() != Some(&card_identity) {
2524        return Err(invalid_feed(
2525            "resolve card identity differs from the verified feed identity",
2526        ));
2527    }
2528    Ok(resolved)
2529}
2530
2531/// Resolve one record strictly from the exact signed snapshot pack. The hub's
2532/// mutable query/index result is intentionally not consulted: a signed pack
2533/// digest is the only cryptographic binding between a feed checkpoint and
2534/// record bytes in wire profile v1.
2535fn resolve_from_verified_pack(
2536    brain: &str,
2537    target: &AddressTarget,
2538    pack: Vec<u8>,
2539) -> LinkResult<Value> {
2540    let entries = parse_store_pack(pack)?;
2541    let mut matched: Option<(String, Vec<u8>)> = None;
2542
2543    for (path, bytes) in entries {
2544        let is_candidate = match target {
2545            AddressTarget::Path(want) => &path == want,
2546            AddressTarget::Id(_) => {
2547                path.ends_with(".md")
2548                    && (path.starts_with("records/") || path.starts_with("sources/"))
2549            }
2550        };
2551        if !is_candidate {
2552            continue;
2553        }
2554        let text = std::str::from_utf8(&bytes)
2555            .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2556        let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2557            .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2558        if let AddressTarget::Id(want) = target {
2559            let frontmatter =
2560                crate::parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(&path))
2561                    .map_err(|_| {
2562                        invalid_feed(format!("signed snapshot record `{path}` is malformed"))
2563                    })?;
2564            if frontmatter.id.as_deref() != Some(want) {
2565                continue;
2566            }
2567        }
2568        if matched.is_some() {
2569            return Err(invalid_feed(
2570                "signed snapshot contains more than one record for the requested target",
2571            ));
2572        }
2573        matched = Some((path, bytes));
2574    }
2575
2576    let (path, bytes) = matched.ok_or_else(|| LinkError::Http {
2577        what: "resolve",
2578        status: 404,
2579        message: "record not found".to_string(),
2580        code: Some("NOT_FOUND".to_string()),
2581        details: None,
2582    })?;
2583    resolve_from_verified_record_bytes(brain, target, path, bytes)
2584}
2585
2586fn resolve_from_verified_record_bytes(
2587    brain: &str,
2588    target: &AddressTarget,
2589    path: String,
2590    bytes: Vec<u8>,
2591) -> LinkResult<Value> {
2592    match target {
2593        AddressTarget::Path(expected) if expected != &path => {
2594            return Err(invalid_feed(
2595                "verified record path differs from the requested path",
2596            ));
2597        }
2598        AddressTarget::Id(_) if !(path.starts_with("records/") || path.starts_with("sources/")) => {
2599            return Err(invalid_feed(
2600                "verified id resolved outside records or sources",
2601            ));
2602        }
2603        _ => {}
2604    }
2605    let text = std::str::from_utf8(&bytes)
2606        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2607    let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2608        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2609    let frontmatter: Value = serde_norway::from_str(&parsed.frontmatter_yaml)
2610        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2611    let Value::Object(fields) = frontmatter else {
2612        return Err(invalid_feed(format!(
2613            "signed snapshot record `{path}` frontmatter is not a mapping"
2614        )));
2615    };
2616    if let AddressTarget::Id(expected) = target {
2617        if fields.get("id").and_then(Value::as_str) != Some(expected.as_str()) {
2618            return Err(invalid_feed(
2619                "verified record id differs from the requested id",
2620            ));
2621        }
2622    }
2623    let mut document = serde_json::Map::new();
2624    document.insert("path".to_string(), Value::String(path));
2625    for (key, value) in fields {
2626        document.insert(key, value);
2627    }
2628    document.insert("body".to_string(), Value::String(parsed.body));
2629    document.insert(
2630        "contentSha".to_string(),
2631        Value::String(content_sha256(&bytes)),
2632    );
2633    Ok(json!({
2634        "brain": brain,
2635        "document": Value::Object(document),
2636    }))
2637}
2638
2639// ─────────────────────────────────────────────────────────────────────────────
2640// sync — pull the granted slice as files; push the local store as a snapshot
2641// ─────────────────────────────────────────────────────────────────────────────
2642
2643/// What a pull materialized.
2644#[derive(Debug, Clone, serde::Serialize)]
2645pub struct PullReport {
2646    /// The brain id the hub reported.
2647    pub brain: String,
2648    /// The brain's slug.
2649    pub slug: String,
2650    /// The hub's feed head at export time.
2651    #[serde(rename = "headSeq")]
2652    pub head_seq: u64,
2653    /// How many files were written.
2654    pub files: usize,
2655    /// Where they were written (as given or derived from the slug).
2656    pub dest: String,
2657    /// Local content files that the export did not carry — present so a
2658    /// caller sees divergence; nothing is ever deleted locally.
2659    #[serde(rename = "extraLocal")]
2660    pub extra_local: Vec<String>,
2661    /// Exact convergence result from the post-install barrier.
2662    #[serde(rename = "syncStatus")]
2663    pub sync_status: String,
2664}
2665
2666struct V2PulledSnapshot {
2667    report: PullReport,
2668    head: V2VerifiedHead,
2669    files: std::collections::BTreeMap<String, V2BaselineFile>,
2670    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
2671    local: V2LocalView,
2672    local_assets: std::collections::BTreeMap<String, crate::AssetRecord>,
2673}
2674
2675fn download_verified_snapshot_pack(
2676    cfg: &HubConfig,
2677    brain: &str,
2678    remote: &VerifiedRemote,
2679) -> LinkResult<Vec<u8>> {
2680    let feed_hash = remote
2681        .head
2682        .feed_hash
2683        .as_deref()
2684        .ok_or_else(|| invalid_feed("non-empty snapshot has no verified feed hash"))?;
2685    let signed_head = remote
2686        .head_entry
2687        .as_ref()
2688        .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
2689    let expected = &signed_head.entry.pack_sha256;
2690    if !is_sha256(expected) {
2691        return Err(invalid_feed(
2692            "signed head carries an invalid snapshot pack digest",
2693        ));
2694    }
2695    let path = format!(
2696        "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={feed_hash}",
2697        remote.head.seq
2698    );
2699    let body = ensure_ok(
2700        request(cfg, "GET", &path, None, Auth::Required)?,
2701        "sync pull",
2702    )?;
2703    if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2704        || body.get("feedHash").and_then(Value::as_str) != Some(feed_hash)
2705        || body.get("brain").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2706        || body.get("sha256").and_then(Value::as_str) != Some(expected.as_str())
2707    {
2708        return Err(invalid_feed(
2709            "export response is not bound to the exact verified snapshot",
2710        ));
2711    }
2712    let url = body
2713        .get("url")
2714        .and_then(Value::as_str)
2715        .ok_or_else(|| invalid_feed("verified snapshot export carried no exact pack URL"))?;
2716    let bytes = get_presigned(cfg, url)?;
2717    if content_sha256(&bytes) != *expected {
2718        return Err(LinkError::InvalidPack {
2719            message: "downloaded pack does not match the signed snapshot digest".to_string(),
2720        });
2721    }
2722    let entries = parse_store_pack(bytes.clone())?;
2723    if signed_head.entry.kind == "push" {
2724        verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
2725    }
2726    Ok(bytes)
2727}
2728
2729#[derive(Debug, Clone, Deserialize, Serialize)]
2730struct V2PointerBody {
2731    v: u8,
2732    brain: String,
2733    seq: u64,
2734    commit_hash: String,
2735    feed_hash: String,
2736    content_root: Option<String>,
2737    asset_root: Option<String>,
2738    materializer: String,
2739    signer_epoch: u64,
2740    control_revision: String,
2741    backup_preparation: String,
2742    prior_pointer_hash: Option<String>,
2743    signed_at: String,
2744}
2745
2746#[derive(Debug, Clone, Deserialize)]
2747struct V2SignedPointer {
2748    pointer: V2PointerBody,
2749    hub_public_key: String,
2750    hub_fingerprint: String,
2751    sig: String,
2752}
2753
2754#[derive(Debug, Clone, Deserialize)]
2755struct V2HeadIdentity {
2756    #[serde(default)]
2757    custody: String,
2758    fingerprint: String,
2759    public_key_spki: String,
2760    #[serde(default)]
2761    previous: Vec<V2PreviousIdentity>,
2762    #[serde(default)]
2763    rotations: Vec<String>,
2764}
2765
2766#[derive(Debug, Clone, Deserialize)]
2767struct V2PreviousIdentity {
2768    fingerprint: String,
2769    public_key_spki: String,
2770}
2771
2772#[derive(Debug, Deserialize)]
2773struct V2HeadResponse {
2774    v: u8,
2775    brain_id: String,
2776    profile: String,
2777    view: Option<V2HeadView>,
2778    pointer: Option<V2SignedPointer>,
2779    identity: Option<V2HeadIdentity>,
2780}
2781
2782#[derive(Debug, Clone, Deserialize)]
2783struct V2HeadView {
2784    kind: String,
2785    #[serde(default)]
2786    id: Option<String>,
2787    control_revision: String,
2788}
2789
2790#[derive(Debug, Clone)]
2791struct V2VerifiedHead {
2792    requested: String,
2793    brain_id: String,
2794    view_kind: String,
2795    /// Stable identity of the exact current-content path projection.
2796    view_revision: String,
2797    /// Use-time authority revision independently bound into writes/signatures.
2798    control_revision: String,
2799    identity: V2HeadIdentity,
2800    pointer: Option<V2PointerBody>,
2801    trust: TrustState,
2802    alias: Option<AliasBinding>,
2803}
2804
2805fn verify_v2_spki_signature(
2806    public_key: &str,
2807    message: &[u8],
2808    signature: &str,
2809) -> LinkResult<Vec<u8>> {
2810    let der = URL_SAFE_NO_PAD
2811        .decode(public_key)
2812        .map_err(|_| invalid_feed("v2 signer public key is not base64url"))?;
2813    if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
2814        return Err(invalid_feed("v2 signer public key is not Ed25519 SPKI"));
2815    }
2816    let sig = URL_SAFE_NO_PAD
2817        .decode(signature)
2818        .map_err(|_| invalid_feed("v2 signature is not base64url"))?;
2819    UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
2820        .verify(message, &sig)
2821        .map_err(|_| invalid_feed("v2 Ed25519 signature failed"))?;
2822    Ok(der)
2823}
2824
2825fn verify_v2_pointer(pointer: &V2SignedPointer, expected_brain: &str) -> LinkResult<String> {
2826    if pointer.pointer.v != 2
2827        || pointer.pointer.brain != expected_brain
2828        || pointer.pointer.seq == 0
2829        || !is_sha256(&pointer.pointer.commit_hash)
2830        || !is_sha256(&pointer.pointer.feed_hash)
2831        || pointer
2832            .pointer
2833            .content_root
2834            .as_deref()
2835            .is_some_and(|hash| !is_sha256(hash))
2836        || !is_sha256(&pointer.pointer.backup_preparation)
2837    {
2838        return Err(invalid_feed("v2 pointer fields are invalid"));
2839    }
2840    let value = serde_json::to_value(&pointer.pointer)
2841        .map_err(|_| invalid_feed("v2 pointer could not be canonicalized"))?;
2842    let message = crate::linkmd_v2::canonical_bytes(&value)
2843        .map_err(|error| invalid_feed(error.to_string()))?;
2844    let der = verify_v2_spki_signature(&pointer.hub_public_key, &message, &pointer.sig)?;
2845    let fingerprint = format!("{:x}", Sha256::digest(&der));
2846    if fingerprint != pointer.hub_fingerprint {
2847        return Err(invalid_feed("v2 hub signer fingerprint mismatch"));
2848    }
2849    Ok(format!(
2850        "{}:{}",
2851        pointer.hub_fingerprint, pointer.hub_public_key
2852    ))
2853}
2854
2855fn v2_identity(identity: &V2HeadIdentity) -> FeedIdentity {
2856    FeedIdentity {
2857        fingerprint: identity.fingerprint.clone(),
2858        public_key_spki: identity.public_key_spki.clone(),
2859        previous: identity
2860            .previous
2861            .iter()
2862            .map(|previous| PreviousIdentity {
2863                fingerprint: previous.fingerprint.clone(),
2864                public_key_spki: previous.public_key_spki.clone(),
2865            })
2866            .collect(),
2867        rotations: identity.rotations.clone(),
2868    }
2869}
2870
2871fn verified_v2_commit_object(
2872    raw: &[u8],
2873    identity: &V2HeadIdentity,
2874) -> LinkResult<serde_json::Map<String, Value>> {
2875    let mut value: Value =
2876        serde_json::from_slice(raw).map_err(|_| invalid_feed("v2 commit is not JSON"))?;
2877    let canonical = crate::linkmd_v2::canonical_bytes(&value)
2878        .map_err(|error| invalid_feed(error.to_string()))?;
2879    if canonical != raw {
2880        return Err(invalid_feed("v2 commit is not canonical JSON"));
2881    }
2882    let object = value
2883        .as_object_mut()
2884        .ok_or_else(|| invalid_feed("v2 commit is not an object"))?;
2885    let sig = object
2886        .remove("sig")
2887        .and_then(|value| value.as_str().map(str::to_string))
2888        .ok_or_else(|| invalid_feed("v2 commit has no signature"))?;
2889    const FIELDS: [&str; 18] = [
2890        "actor_ref",
2891        "asset_root",
2892        "brain",
2893        "changes_sha256",
2894        "control_revision",
2895        "materializer",
2896        "op",
2897        "parent_asset_root",
2898        "parent_commit",
2899        "parent_root",
2900        "prev_entry_hash",
2901        "public_key",
2902        "seq",
2903        "signer_epoch",
2904        "state_root",
2905        "ts",
2906        "v",
2907        "v1_bridge",
2908    ];
2909    if object.len() != FIELDS.len() || FIELDS.iter().any(|field| !object.contains_key(*field)) {
2910        return Err(invalid_feed("v2 commit has a non-normative field set"));
2911    }
2912    let seq = object
2913        .get("seq")
2914        .and_then(Value::as_u64)
2915        .filter(|seq| *seq > 0)
2916        .ok_or_else(|| invalid_feed("v2 commit has an invalid sequence"))?;
2917    let signer_epoch = object
2918        .get("signer_epoch")
2919        .and_then(Value::as_u64)
2920        .filter(|epoch| *epoch > 0)
2921        .ok_or_else(|| invalid_feed("v2 commit has an invalid signer epoch"))?;
2922    let hash_or_null = |field: &str| {
2923        object
2924            .get(field)
2925            .is_some_and(|value| value.is_null() || value.as_str().is_some_and(is_sha256))
2926    };
2927    if object.get("v").and_then(Value::as_u64) != Some(2)
2928        || object.get("op").and_then(Value::as_str) != Some("changeset")
2929        || !object
2930            .get("changes_sha256")
2931            .and_then(Value::as_str)
2932            .is_some_and(is_sha256)
2933        || !object
2934            .get("actor_ref")
2935            .and_then(Value::as_str)
2936            .is_some_and(is_sha256)
2937        || !object
2938            .get("control_revision")
2939            .and_then(Value::as_str)
2940            .is_some_and(is_sha256)
2941        || !object
2942            .get("state_root")
2943            .and_then(Value::as_str)
2944            .is_some_and(is_sha256)
2945        || !hash_or_null("parent_commit")
2946        || !hash_or_null("parent_root")
2947        || !hash_or_null("parent_asset_root")
2948        || !hash_or_null("asset_root")
2949        || !hash_or_null("prev_entry_hash")
2950        || !object
2951            .get("materializer")
2952            .and_then(Value::as_str)
2953            .is_some_and(|value| !value.is_empty() && value.len() <= 128)
2954        || !object
2955            .get("ts")
2956            .and_then(Value::as_str)
2957            .is_some_and(|value| value.len() == 24 && value.ends_with('Z'))
2958    {
2959        return Err(invalid_feed("v2 commit fields are invalid"));
2960    }
2961    if (seq == 1
2962        && [
2963            "parent_commit",
2964            "parent_root",
2965            "parent_asset_root",
2966            "prev_entry_hash",
2967        ]
2968        .iter()
2969        .any(|field| !object.get(*field).is_some_and(Value::is_null)))
2970        || (seq > 1
2971            && ["parent_commit", "parent_root", "prev_entry_hash"]
2972                .iter()
2973                .any(|field| object.get(*field).and_then(Value::as_str).is_none()))
2974    {
2975        return Err(invalid_feed("v2 commit parent shape is invalid"));
2976    }
2977    match object.get("v1_bridge") {
2978        Some(Value::Null) => {}
2979        Some(Value::Object(bridge))
2980            if seq == 1
2981                && bridge.len() == 3
2982                && bridge
2983                    .get("head_seq")
2984                    .and_then(Value::as_u64)
2985                    .is_some_and(|v| v > 0)
2986                && bridge
2987                    .get("feed_hash")
2988                    .and_then(Value::as_str)
2989                    .is_some_and(is_sha256)
2990                && bridge
2991                    .get("pack_sha256")
2992                    .and_then(Value::as_str)
2993                    .is_some_and(is_sha256) => {}
2994        _ => return Err(invalid_feed("v2 commit has an invalid v1 bridge")),
2995    }
2996    let public_key = object
2997        .get("public_key")
2998        .and_then(Value::as_str)
2999        .ok_or_else(|| invalid_feed("v2 commit has no public key"))?;
3000    let der = URL_SAFE_NO_PAD
3001        .decode(public_key)
3002        .map_err(|_| invalid_feed("v2 brain public key is not base64url"))?;
3003    let expected_multikey = format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&der)));
3004    if object.get("brain").and_then(Value::as_str) != Some(expected_multikey.as_str()) {
3005        return Err(invalid_feed("v2 commit brain identity mismatch"));
3006    }
3007    // Verify the old-key-signed chain before trusting any prior public key.
3008    verify_identity_chain(&v2_identity(identity), None)?;
3009    // Old commits remain verifiable after brain-key rotation. `previous` is
3010    // newest-first, while signer epochs and rotation statements are oldest-first.
3011    let mut chain: Vec<(&str, &str)> = identity
3012        .previous
3013        .iter()
3014        .rev()
3015        .map(|previous| {
3016            (
3017                previous.fingerprint.as_str(),
3018                previous.public_key_spki.as_str(),
3019            )
3020        })
3021        .collect();
3022    chain.push((&identity.fingerprint, &identity.public_key_spki));
3023    let signer_index = chain.iter().position(|(fingerprint, spki)| {
3024        *fingerprint == expected_multikey.trim_start_matches("ed25519:") && *spki == public_key
3025    });
3026    let Some(signer_index) = signer_index else {
3027        return Err(invalid_feed("v2 commit uses an unrecognized brain key"));
3028    };
3029    if signer_epoch != signer_index as u64 + 1 {
3030        return Err(invalid_feed("v2 commit signer epoch differs from its key"));
3031    }
3032    let lower_boundary = if signer_index == 0 {
3033        None
3034    } else {
3035        let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
3036            .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
3037        Some(prior.prior_head_seq)
3038    };
3039    let upper_boundary = if signer_index == identity.rotations.len() {
3040        None
3041    } else {
3042        let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
3043            .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
3044        Some(next.prior_head_seq)
3045    };
3046    if lower_boundary.is_some_and(|boundary| seq <= boundary)
3047        || upper_boundary.is_some_and(|boundary| seq > boundary)
3048    {
3049        return Err(invalid_feed(
3050            "v2 commit signer is outside its authenticated rotation epoch",
3051        ));
3052    }
3053    let unsigned = crate::linkmd_v2::canonical_bytes(&Value::Object(object.clone()))
3054        .map_err(|error| invalid_feed(error.to_string()))?;
3055    verify_v2_spki_signature(public_key, &unsigned, &sig)?;
3056    Ok(object.clone())
3057}
3058
3059#[derive(Debug, Deserialize)]
3060struct V2FeedWireEntry {
3061    seq: u64,
3062    commit_hash: String,
3063    feed_hash: String,
3064    bytes_base64: String,
3065}
3066
3067#[derive(Debug, Deserialize)]
3068struct V2FeedPage {
3069    v: u8,
3070    head_seq: u64,
3071    head_commit_hash: String,
3072    head_feed_hash: String,
3073    entries: Vec<V2FeedWireEntry>,
3074    next_after: u64,
3075    complete: bool,
3076}
3077
3078fn replay_v2_feed(
3079    cfg: &HubConfig,
3080    brain: &str,
3081    pointer: &V2PointerBody,
3082    identity: &V2HeadIdentity,
3083    start_after: u64,
3084    start_feed: Option<String>,
3085) -> LinkResult<()> {
3086    let mut after = start_after;
3087    let mut prior_feed = start_feed;
3088    let mut final_object = None;
3089    let mut replayed_entries = 0_u64;
3090    let mut replayed_bytes = 0_u64;
3091    while after < pointer.seq {
3092        let path = format!("/api/hub/brains/{brain}/v2/feed?after={after}&limit=100");
3093        let value = ensure_ok(
3094            request_capped(
3095                cfg,
3096                "GET",
3097                &path,
3098                None,
3099                Auth::Required,
3100                MAX_FEED_REPLAY_BYTES,
3101            )?,
3102            "v2 feed replay",
3103        )?;
3104        let page: V2FeedPage = serde_json::from_value(value)
3105            .map_err(|_| invalid_feed("v2 feed page has an invalid shape"))?;
3106        if page.v != 2
3107            || page.head_seq != pointer.seq
3108            || page.head_commit_hash != pointer.commit_hash
3109            || page.head_feed_hash != pointer.feed_hash
3110            || page.entries.is_empty()
3111            || page.entries.len() > FEED_PAGE_LIMIT
3112        {
3113            return Err(invalid_feed("v2 feed page differs from the signed head"));
3114        }
3115        for entry in page.entries {
3116            if entry.seq != after + 1
3117                || !is_sha256(&entry.commit_hash)
3118                || !is_sha256(&entry.feed_hash)
3119            {
3120                return Err(invalid_feed("v2 feed sequence is not contiguous"));
3121            }
3122            let raw = base64::engine::general_purpose::STANDARD
3123                .decode(&entry.bytes_base64)
3124                .map_err(|_| invalid_feed("v2 feed bytes are not canonical base64"))?;
3125            replayed_entries = replayed_entries
3126                .checked_add(1)
3127                .ok_or_else(|| invalid_feed("v2 feed replay count overflow"))?;
3128            replayed_bytes = replayed_bytes
3129                .checked_add(raw.len() as u64)
3130                .ok_or_else(|| invalid_feed("v2 feed replay byte count overflow"))?;
3131            if replayed_entries > MAX_FEED_REPLAY_ENTRIES || replayed_bytes > MAX_FEED_REPLAY_BYTES
3132            {
3133                return Err(invalid_feed("v2 feed replay exceeds its safety bound"));
3134            }
3135            if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3136                .map_err(|error| invalid_feed(error.to_string()))?
3137                != entry.commit_hash
3138                || content_sha256(&raw) != entry.feed_hash
3139            {
3140                return Err(invalid_feed("v2 feed entry address mismatch"));
3141            }
3142            let object = verified_v2_commit_object(&raw, identity)?;
3143            if object.get("seq").and_then(Value::as_u64) != Some(entry.seq)
3144                || object.get("prev_entry_hash").and_then(Value::as_str) != prior_feed.as_deref()
3145            {
3146                return Err(invalid_feed(
3147                    "v2 feed entry does not extend its predecessor",
3148                ));
3149            }
3150            after = entry.seq;
3151            prior_feed = Some(entry.feed_hash);
3152            final_object = Some((entry.commit_hash, object));
3153        }
3154        if page.next_after != after || (page.complete != (after == pointer.seq)) {
3155            return Err(invalid_feed("v2 feed page cursor is inconsistent"));
3156        }
3157    }
3158    let (final_hash, object) =
3159        final_object.ok_or_else(|| invalid_feed("v2 feed replay made no progress"))?;
3160    if final_hash != pointer.commit_hash
3161        || prior_feed.as_deref() != Some(pointer.feed_hash.as_str())
3162        || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
3163        || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
3164        || object.get("control_revision").and_then(Value::as_str)
3165            != Some(pointer.control_revision.as_str())
3166        || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
3167    {
3168        return Err(invalid_feed(
3169            "v2 replay did not converge on the signed pointer",
3170        ));
3171    }
3172    Ok(())
3173}
3174
3175fn verify_v1_to_v2_bridge(
3176    cfg: &HubConfig,
3177    brain: &str,
3178    pointer: &V2PointerBody,
3179    identity: &V2HeadIdentity,
3180    checkpoint: &TrustState,
3181) -> LinkResult<()> {
3182    let value = ensure_ok(
3183        request_capped(
3184            cfg,
3185            "GET",
3186            &format!("/api/hub/brains/{brain}/v2/feed?after=0&limit=1"),
3187            None,
3188            Auth::Required,
3189            MAX_FEED_RESPONSE_BYTES,
3190        )?,
3191        "v2 genesis bridge",
3192    )?;
3193    let page: V2FeedPage = serde_json::from_value(value)
3194        .map_err(|_| invalid_feed("v2 genesis bridge page has an invalid shape"))?;
3195    if page.v != 2
3196        || page.head_seq != pointer.seq
3197        || page.head_commit_hash != pointer.commit_hash
3198        || page.head_feed_hash != pointer.feed_hash
3199        || page.entries.len() != 1
3200        || page.entries[0].seq != 1
3201        || !is_sha256(&page.entries[0].commit_hash)
3202        || !is_sha256(&page.entries[0].feed_hash)
3203    {
3204        return Err(invalid_feed(
3205            "v2 genesis bridge page differs from the signed head",
3206        ));
3207    }
3208    let first = &page.entries[0];
3209    let raw = STANDARD
3210        .decode(&first.bytes_base64)
3211        .map_err(|_| invalid_feed("v2 genesis bridge bytes are not canonical base64"))?;
3212    if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3213        .map_err(|error| invalid_feed(error.to_string()))?
3214        != first.commit_hash
3215        || content_sha256(&raw) != first.feed_hash
3216    {
3217        return Err(invalid_feed("v2 genesis bridge address mismatch"));
3218    }
3219    let object = verified_v2_commit_object(&raw, identity)?;
3220    if checkpoint.head_seq == 0 {
3221        if checkpoint.feed_hash.is_some() || object.get("v1_bridge") != Some(&Value::Null) {
3222            return Err(invalid_feed(
3223                "empty v1 checkpoint did not transition through an empty v2 genesis",
3224            ));
3225        }
3226        return Ok(());
3227    }
3228    let bridge = object
3229        .get("v1_bridge")
3230        .and_then(Value::as_object)
3231        .ok_or_else(|| invalid_feed("v2 genesis omitted the pinned v1 boundary"))?;
3232    let checkpoint_feed = checkpoint
3233        .feed_hash
3234        .as_deref()
3235        .ok_or_else(|| invalid_feed("non-empty v1 checkpoint has no feed hash"))?;
3236    if bridge.get("head_seq").and_then(Value::as_u64) != Some(checkpoint.head_seq)
3237        || bridge.get("feed_hash").and_then(Value::as_str) != Some(checkpoint_feed)
3238    {
3239        return Err(invalid_feed(
3240            "v2 genesis bridge differs from the pinned v1 checkpoint",
3241        ));
3242    }
3243    let legacy_raw = ensure_raw_ok(
3244        request_raw(
3245            cfg,
3246            "GET",
3247            &format!(
3248                "/api/hub/brains/{brain}/feed?after={}&limit=1",
3249                checkpoint.head_seq - 1
3250            ),
3251            None,
3252            Auth::Required,
3253            MAX_FEED_RESPONSE_BYTES,
3254        )?,
3255        "v1 bridge boundary",
3256    )?;
3257    let legacy: FeedResponse = serde_json::from_slice(&legacy_raw)
3258        .map_err(|_| invalid_feed("v1 bridge boundary has an invalid feed shape"))?;
3259    let legacy_identity = legacy
3260        .identity
3261        .ok_or_else(|| invalid_feed("v1 bridge boundary has no identity"))?;
3262    let item = legacy
3263        .entries
3264        .first()
3265        .filter(|_| legacy.entries.len() == 1)
3266        .ok_or_else(|| invalid_feed("v1 bridge boundary did not return one exact entry"))?;
3267    if legacy.scope_limited
3268        || legacy.head_seq != checkpoint.head_seq
3269        || legacy.feed_hash.as_deref() != Some(checkpoint_feed)
3270        || item.entry.seq != checkpoint.head_seq
3271        || item.hash != checkpoint_feed
3272        || legacy_identity != v2_identity(identity)
3273        || bridge.get("pack_sha256").and_then(Value::as_str)
3274            != Some(item.entry.pack_sha256.as_str())
3275    {
3276        return Err(invalid_feed(
3277            "v1 bridge boundary differs from its signed legacy head",
3278        ));
3279    }
3280    let anchor = verify_identity_chain(&legacy_identity, Some(checkpoint))?;
3281    if anchor != checkpoint.anchor {
3282        return Err(invalid_feed("v1 bridge changed the pinned identity anchor"));
3283    }
3284    verify_feed_item(item, &legacy_identity)?;
3285    verify_rotation_feed_boundaries(
3286        &legacy_identity,
3287        Some(checkpoint),
3288        std::slice::from_ref(item),
3289        checkpoint.head_seq,
3290    )?;
3291    Ok(())
3292}
3293
3294fn verify_v2_commit(
3295    cfg: &HubConfig,
3296    brain: &str,
3297    pointer: &V2PointerBody,
3298    identity: &V2HeadIdentity,
3299    pinned: Option<&TrustState>,
3300) -> LinkResult<()> {
3301    let path = format!(
3302        "/api/hub/brains/{brain}/v2/commit?commit={}",
3303        pointer.commit_hash
3304    );
3305    let raw = ensure_raw_ok(
3306        request_raw(cfg, "GET", &path, None, Auth::Required, MAX_RESPONSE_BYTES)?,
3307        "v2 commit",
3308    )?;
3309    if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3310        .map_err(|error| invalid_feed(error.to_string()))?
3311        != pointer.commit_hash
3312        || content_sha256(&raw) != pointer.feed_hash
3313    {
3314        return Err(invalid_feed("v2 commit address differs from the pointer"));
3315    }
3316    let object = verified_v2_commit_object(&raw, identity)?;
3317    if object.get("seq").and_then(Value::as_u64) != Some(pointer.seq)
3318        || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
3319        || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
3320        || object.get("control_revision").and_then(Value::as_str)
3321            != Some(pointer.control_revision.as_str())
3322        || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
3323    {
3324        return Err(invalid_feed("v2 commit fields differ from the pointer"));
3325    }
3326    if let Some(checkpoint) = pinned.filter(|checkpoint| accepted_as_v2(checkpoint)) {
3327        if pointer.seq == checkpoint.head_seq + 1
3328            && object.get("prev_entry_hash").and_then(Value::as_str)
3329                != checkpoint.feed_hash.as_deref()
3330        {
3331            return Err(invalid_feed(
3332                "v2 commit does not extend the pinned feed hash",
3333            ));
3334        }
3335        if pointer.seq > checkpoint.head_seq + 1 {
3336            return replay_v2_feed(
3337                cfg,
3338                brain,
3339                pointer,
3340                identity,
3341                checkpoint.head_seq,
3342                checkpoint.feed_hash.clone(),
3343            );
3344        }
3345    } else {
3346        if let Some(checkpoint) = pinned {
3347            verify_v1_to_v2_bridge(cfg, brain, pointer, identity, checkpoint)?;
3348        }
3349        if pointer.seq > 1 {
3350            return replay_v2_feed(cfg, brain, pointer, identity, 0, None);
3351        }
3352    }
3353    Ok(())
3354}
3355
3356fn v2_verified_head(cfg: &HubConfig, brain: &str) -> LinkResult<Option<V2VerifiedHead>> {
3357    require_hardened_filesystem("verified link.md v2 state")?;
3358    require_safe_ref(brain)?;
3359    // Open and pin the private trust directory before a credential can leave
3360    // the process. A hostile checkout must not turn an unsafe state root into
3361    // a network oracle or receive a bearer before checkpoint safety is known.
3362    let trust_directory = open_trust_dir(cfg)?;
3363    let path = format!("/api/hub/brains/{brain}/v2/head");
3364    let response = request(cfg, "GET", &path, None, Auth::Required)?;
3365    if response.status == 404 {
3366        if has_accepted_v2_ref(cfg, brain)? {
3367            return Err(LinkError::BrainUnavailable);
3368        }
3369        return Ok(None);
3370    }
3371    let body = ensure_ok(response, "v2 head")?;
3372    let head: V2HeadResponse = serde_json::from_value(body)
3373        .map_err(|_| invalid_feed("v2 head response has an invalid shape"))?;
3374    if head.v != 2 || !crate::ulid::is_ulid(&head.brain_id) {
3375        return Err(invalid_feed("v2 head has no canonical brain id"));
3376    }
3377    if crate::ulid::is_ulid(brain) && head.brain_id != brain {
3378        return Err(invalid_feed("v2 head resolved a different brain id"));
3379    }
3380    if head.profile == "v1" {
3381        return Ok(None);
3382    }
3383    if head.profile != "v2" && head.profile != "v2-empty" {
3384        return Err(invalid_feed("v2 head advertised an unknown profile"));
3385    }
3386    let view = head
3387        .view
3388        .as_ref()
3389        .ok_or_else(|| invalid_feed("v2 head has no permission view"))?;
3390    if !matches!(view.kind.as_str(), "full" | "scoped")
3391        || !is_sha256(&view.control_revision)
3392        || view.id.as_deref().is_some_and(|id| !is_sha256(id))
3393    {
3394        return Err(invalid_feed("v2 head has an invalid permission view"));
3395    }
3396    let view_kind = view.kind.clone();
3397    // `id` is the stable materialized namespace. Older v2 hubs did not expose
3398    // it, so retain the fail-closed control-revision fallback for compatibility.
3399    let view_revision = view
3400        .id
3401        .clone()
3402        .unwrap_or_else(|| view.control_revision.clone());
3403    let control_revision = view.control_revision.clone();
3404    let identity = head
3405        .identity
3406        .as_ref()
3407        .ok_or_else(|| invalid_feed("v2 head has no brain identity"))?;
3408    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &head.brain_id])?;
3409    let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, brain, &head.brain_id)?;
3410    let feed_identity = v2_identity(identity);
3411    let anchor = verify_identity_chain(&feed_identity, pinned.as_ref())?;
3412    let (seq, feed_hash, hub_signer) = match &head.pointer {
3413        None => {
3414            if head.profile != "v2-empty" {
3415                return Err(invalid_feed("initialized v2 head has no pointer"));
3416            }
3417            (
3418                0,
3419                None,
3420                pinned.as_ref().and_then(|state| state.hub_signer.clone()),
3421            )
3422        }
3423        Some(signed) => {
3424            let signer = verify_v2_pointer(signed, &head.brain_id)?;
3425            if pinned
3426                .as_ref()
3427                .and_then(|state| state.hub_signer.as_ref())
3428                .is_some_and(|known| known != &signer)
3429            {
3430                return Err(invalid_feed(
3431                    "v2 hub pointer signer changed without a trust transition",
3432                ));
3433            }
3434            if let Some(checkpoint) = pinned.as_ref().filter(|state| accepted_as_v2(state)) {
3435                if signed.pointer.seq < checkpoint.head_seq
3436                    || (signed.pointer.seq == checkpoint.head_seq
3437                        && checkpoint.feed_hash.as_deref()
3438                            != Some(signed.pointer.feed_hash.as_str()))
3439                {
3440                    return Err(invalid_feed("v2 pointer rolled back or equivocated"));
3441                }
3442            }
3443            verify_v2_commit(
3444                cfg,
3445                &head.brain_id,
3446                &signed.pointer,
3447                identity,
3448                pinned.as_ref(),
3449            )?;
3450            (
3451                signed.pointer.seq,
3452                Some(signed.pointer.feed_hash.clone()),
3453                Some(signer),
3454            )
3455        }
3456    };
3457    let trust = TrustState {
3458        v: 2,
3459        origin: normalized_origin(&cfg.hub)?,
3460        requested: head.brain_id.clone(),
3461        brain: head.brain_id.clone(),
3462        home: None,
3463        anchor,
3464        current: format!("ed25519:{}", identity.fingerprint),
3465        head_seq: seq,
3466        feed_hash,
3467        rotations: identity.rotations.clone(),
3468        hub_signer,
3469        protocol_profile: Some("link-v2".to_string()),
3470    };
3471    Ok(Some(V2VerifiedHead {
3472        requested: brain.to_string(),
3473        brain_id: head.brain_id,
3474        view_kind,
3475        view_revision,
3476        control_revision,
3477        identity: identity.clone(),
3478        pointer: head.pointer.map(|signed| signed.pointer),
3479        trust,
3480        alias: alias_binding,
3481    }))
3482}
3483
3484fn accept_v2_head(cfg: &HubConfig, head: &V2VerifiedHead) -> LinkResult<()> {
3485    let directory = open_trust_dir(cfg)?;
3486    let _locks = lock_trust_many(cfg, &directory, &[&head.requested, &head.brain_id])?;
3487    let (current, alias) = load_canonical_pin(cfg, &directory, &head.requested, &head.brain_id)?;
3488    if let Some(current) = current {
3489        let common_invalid = head.trust.anchor != current.anchor
3490            || !head.trust.rotations.starts_with(&current.rotations);
3491        let profile_invalid = if accepted_as_v2(&current) {
3492            head.trust.head_seq < current.head_seq
3493                || (head.trust.head_seq == current.head_seq
3494                    && head.trust.feed_hash != current.feed_hash)
3495                || current
3496                    .hub_signer
3497                    .as_ref()
3498                    .is_some_and(|known| head.trust.hub_signer.as_ref() != Some(known))
3499        } else {
3500            head.trust.protocol_profile.as_deref() != Some("link-v2")
3501                || head.trust.hub_signer.is_none()
3502        };
3503        if common_invalid || profile_invalid {
3504            return Err(invalid_feed(
3505                "v2 head cannot advance the currently accepted trust checkpoint",
3506            ));
3507        }
3508    }
3509    save_canonical_pin_and_alias(
3510        cfg,
3511        &directory,
3512        &head.requested,
3513        &head.brain_id,
3514        head.trust.clone(),
3515        alias.as_ref().or(head.alias.as_ref()),
3516    )
3517}
3518
3519#[derive(Debug, Clone, Deserialize, Serialize)]
3520struct V2BaselineFile {
3521    sha256: String,
3522    bytes: u64,
3523    #[serde(skip)]
3524    proof: Option<Vec<V2ProofStep>>,
3525}
3526
3527#[derive(Debug, Clone, Deserialize, Serialize)]
3528struct V2SyncBaseline {
3529    v: u8,
3530    origin: String,
3531    brain: String,
3532    #[serde(default)]
3533    checkout_id: Option<String>,
3534    #[serde(default)]
3535    head_seq: Option<u64>,
3536    commit_hash: Option<String>,
3537    content_root: Option<String>,
3538    #[serde(default)]
3539    asset_root: Option<String>,
3540    #[serde(default)]
3541    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
3542    #[serde(default)]
3543    view_kind: Option<String>,
3544    #[serde(default)]
3545    view_revision: Option<String>,
3546    #[serde(default)]
3547    projection_sha256: Option<String>,
3548    files: std::collections::BTreeMap<String, V2BaselineFile>,
3549    #[serde(default)]
3550    local_policy_digest: Option<String>,
3551    #[serde(default)]
3552    local_eligibility: std::collections::BTreeMap<String, bool>,
3553    #[serde(default)]
3554    remote_copy_remains: std::collections::BTreeMap<String, String>,
3555}
3556
3557struct V2LocalView {
3558    riding: std::collections::BTreeMap<String, (String, u64)>,
3559    eligibility: std::collections::BTreeMap<String, bool>,
3560    policy: crate::linkmd_sync_policy::SyncPolicy,
3561    withheld_links: Vec<V2WithheldLink>,
3562}
3563
3564#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
3565struct V2WithheldLink {
3566    source: String,
3567    target: String,
3568}
3569
3570#[derive(Debug, Clone, Deserialize, Serialize)]
3571struct V2ProofStep {
3572    directory_root: String,
3573    component: String,
3574    proof: crate::linkmd_v2::HamtProof,
3575}
3576
3577#[derive(Debug, Deserialize)]
3578struct V2ManifestFile {
3579    path: String,
3580    sha256: String,
3581    bytes: u64,
3582    proof: Vec<V2ProofStep>,
3583}
3584
3585#[derive(Debug, Deserialize)]
3586struct V2ManifestPage {
3587    v: u8,
3588    commit: String,
3589    content_root: Option<String>,
3590    files: Vec<V2ManifestFile>,
3591    next_cursor: Option<String>,
3592}
3593
3594#[derive(Debug, Clone, Deserialize, Serialize)]
3595struct V2BaselineAsset {
3596    blob_sha256: String,
3597    bytes: u64,
3598    media_type: String,
3599    wrappers: Vec<String>,
3600    required: bool,
3601    disposition: String,
3602    leaf_hash: String,
3603}
3604
3605#[derive(Debug, Deserialize)]
3606struct V2AssetManifestItem {
3607    path: String,
3608    blob_sha256: String,
3609    bytes: u64,
3610    media_type: String,
3611    wrappers: Vec<String>,
3612    required: bool,
3613    disposition: String,
3614    leaf_hash: String,
3615    proof: crate::linkmd_v2::HamtProof,
3616}
3617
3618#[derive(Debug, Deserialize)]
3619struct V2AssetManifestPage {
3620    v: u8,
3621    commit: String,
3622    asset_root: Option<String>,
3623    assets: Vec<V2AssetManifestItem>,
3624    next_cursor: Option<String>,
3625}
3626
3627#[derive(Debug, Deserialize)]
3628struct V2SigningCandidate {
3629    seq: u64,
3630    content_root: Option<String>,
3631    asset_root: Option<String>,
3632    signing_bytes_base64: String,
3633    changes_base64: String,
3634    actor_claim_base64: String,
3635}
3636
3637#[derive(Debug, Deserialize)]
3638struct V2SigningCandidatePage {
3639    v: u8,
3640    challenge_id: String,
3641    mutation_id: String,
3642    request_hash: String,
3643    parent: V2SigningParent,
3644    candidate: V2SigningCandidate,
3645    files: Vec<V2ManifestFile>,
3646    #[serde(default)]
3647    assets: Vec<V2AssetManifestItem>,
3648    next_cursor: Option<String>,
3649    expires_at: String,
3650}
3651
3652#[derive(Debug, Deserialize)]
3653struct V2SigningParent {
3654    seq: u64,
3655    commit_hash: Option<String>,
3656}
3657
3658fn verify_v2_file_proof(root: &str, file: &V2ManifestFile) -> LinkResult<()> {
3659    let normalized = crate::linkmd_v2::normalize_path(&file.path)
3660        .map_err(|error| invalid_feed(error.to_string()))?;
3661    let components = normalized.split('/').collect::<Vec<_>>();
3662    if components.len() != file.proof.len() || !is_sha256(&file.sha256) {
3663        return Err(invalid_feed("v2 file proof has the wrong shape"));
3664    }
3665    let mut directory_root = root.to_string();
3666    for (index, step) in file.proof.iter().enumerate() {
3667        if step.directory_root != directory_root || step.component != components[index] {
3668            return Err(invalid_feed(
3669                "v2 file proof path chain differs from its manifest",
3670            ));
3671        }
3672        if !crate::linkmd_v2::verify_proof(&directory_root, &step.component, &step.proof)
3673            .map_err(|error| invalid_feed(error.to_string()))?
3674        {
3675            return Err(invalid_feed("v2 file proof failed verification"));
3676        }
3677        let entry = match &step.proof {
3678            crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry,
3679            crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
3680                return Err(invalid_feed("v2 manifest carried a non-inclusion proof"));
3681            }
3682        };
3683        if index + 1 == components.len() {
3684            if entry.kind != crate::linkmd_v2::EntryKind::Blob
3685                || entry.child_hash != file.sha256
3686                || entry.bytes != Some(file.bytes)
3687            {
3688                return Err(invalid_feed("v2 file proof leaf differs from its manifest"));
3689            }
3690        } else if entry.kind != crate::linkmd_v2::EntryKind::Tree {
3691            return Err(invalid_feed("v2 file proof traversed a non-directory"));
3692        } else {
3693            directory_root = entry.child_hash.clone();
3694        }
3695    }
3696    Ok(())
3697}
3698
3699fn v2_manifest(
3700    cfg: &HubConfig,
3701    brain: &str,
3702    pointer: Option<&V2PointerBody>,
3703) -> LinkResult<std::collections::BTreeMap<String, V2BaselineFile>> {
3704    let Some(pointer) = pointer else {
3705        return Ok(std::collections::BTreeMap::new());
3706    };
3707    let Some(root) = pointer.content_root.as_deref() else {
3708        return Ok(std::collections::BTreeMap::new());
3709    };
3710    let mut files = std::collections::BTreeMap::new();
3711    let mut after = String::new();
3712    loop {
3713        let encoded_after: String =
3714            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3715        let path = format!(
3716            "/api/hub/brains/{brain}/v2/files?commit={}&limit=500&after={encoded_after}",
3717            pointer.commit_hash
3718        );
3719        let value = ensure_ok(
3720            request_capped(
3721                cfg,
3722                "GET",
3723                &path,
3724                None,
3725                Auth::Required,
3726                MAX_FEED_RESPONSE_BYTES,
3727            )?,
3728            "v2 file manifest",
3729        )?;
3730        let page: V2ManifestPage = serde_json::from_value(value)
3731            .map_err(|_| invalid_feed("v2 file manifest has an invalid shape"))?;
3732        if page.v != 2
3733            || page.commit != pointer.commit_hash
3734            || page.content_root.as_deref() != Some(root)
3735            || page.files.len() > 500
3736        {
3737            return Err(invalid_feed(
3738                "v2 file manifest is not bound to the verified head",
3739            ));
3740        }
3741        for file in page.files {
3742            verify_v2_file_proof(root, &file)?;
3743            if files
3744                .insert(
3745                    file.path.clone(),
3746                    V2BaselineFile {
3747                        sha256: file.sha256,
3748                        bytes: file.bytes,
3749                        proof: Some(file.proof),
3750                    },
3751                )
3752                .is_some()
3753            {
3754                return Err(invalid_feed("v2 file manifest repeats a path"));
3755            }
3756            if files.len() > MAX_PUSH_FILES {
3757                return Err(invalid_feed(
3758                    "v2 file manifest exceeds the file-count bound",
3759                ));
3760            }
3761        }
3762        match page.next_cursor {
3763            None => break,
3764            Some(next) if next > after => after = next,
3765            Some(_) => return Err(invalid_feed("v2 file manifest cursor did not advance")),
3766        }
3767    }
3768    Ok(files)
3769}
3770
3771/// Fetch one exact path proof instead of enumerating the whole permission
3772/// view. This keeps `resolve @brain/path` proportional to path depth even for
3773/// a very large brain. The returned leaf is accepted only after its complete
3774/// proof chain reaches the content root signed by `pointer`.
3775fn v2_manifest_file(
3776    cfg: &HubConfig,
3777    brain: &str,
3778    pointer: &V2PointerBody,
3779    path: &str,
3780) -> LinkResult<Option<V2BaselineFile>> {
3781    let Some(root) = pointer.content_root.as_deref() else {
3782        return Ok(None);
3783    };
3784    crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
3785        path: error.to_string(),
3786    })?;
3787    let encoded: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
3788    let value = ensure_ok(
3789        request_capped(
3790            cfg,
3791            "GET",
3792            &format!(
3793                "/api/hub/brains/{brain}/v2/files?commit={}&path={encoded}",
3794                pointer.commit_hash
3795            ),
3796            None,
3797            Auth::Required,
3798            MAX_FEED_RESPONSE_BYTES,
3799        )?,
3800        "v2 exact file proof",
3801    )?;
3802    let mut page: V2ManifestPage = serde_json::from_value(value)
3803        .map_err(|_| invalid_feed("v2 exact file proof has an invalid shape"))?;
3804    if page.v != 2
3805        || page.commit != pointer.commit_hash
3806        || page.content_root.as_deref() != Some(root)
3807        || page.next_cursor.is_some()
3808        || page.files.len() != 1
3809        || page.files[0].path != path
3810    {
3811        return Err(invalid_feed(
3812            "v2 exact file proof is not bound to the requested signed path",
3813        ));
3814    }
3815    let file = page.files.pop().expect("exactly one file was checked");
3816    verify_v2_file_proof(root, &file)?;
3817    Ok(Some(V2BaselineFile {
3818        sha256: file.sha256,
3819        bytes: file.bytes,
3820        proof: Some(file.proof),
3821    }))
3822}
3823
3824/// Resolve an id through the hub's synchronously maintained, content-light
3825/// validation certificate, then require the same exact signed path proof as a
3826/// path lookup. The certificate is only an efficient locator: the downloaded
3827/// bytes are parsed again and their id is checked by the caller.
3828fn v2_manifest_file_by_id(
3829    cfg: &HubConfig,
3830    brain: &str,
3831    pointer: &V2PointerBody,
3832    id: &str,
3833) -> LinkResult<(String, V2BaselineFile)> {
3834    let root = pointer
3835        .content_root
3836        .as_deref()
3837        .ok_or_else(|| LinkError::Http {
3838            what: "resolve",
3839            status: 404,
3840            message: "record not found".to_string(),
3841            code: Some("NOT_FOUND".to_string()),
3842            details: None,
3843        })?;
3844    if !crate::ulid::is_ulid(id) {
3845        return Err(LinkError::BadAddress {
3846            given: id.to_string(),
3847            reason: BAD_TARGET_REASON.to_string(),
3848        });
3849    }
3850    let encoded: String = url::form_urlencoded::byte_serialize(id.as_bytes()).collect();
3851    let value = ensure_ok(
3852        request_capped(
3853            cfg,
3854            "GET",
3855            &format!(
3856                "/api/hub/brains/{brain}/v2/files?commit={}&id={encoded}",
3857                pointer.commit_hash
3858            ),
3859            None,
3860            Auth::Required,
3861            MAX_FEED_RESPONSE_BYTES,
3862        )?,
3863        "v2 exact id proof",
3864    )?;
3865    let mut page: V2ManifestPage = serde_json::from_value(value)
3866        .map_err(|_| invalid_feed("v2 exact id proof has an invalid shape"))?;
3867    if page.v != 2
3868        || page.commit != pointer.commit_hash
3869        || page.content_root.as_deref() != Some(root)
3870        || page.next_cursor.is_some()
3871        || page.files.len() != 1
3872    {
3873        return Err(invalid_feed(
3874            "v2 exact id proof is not bound to one signed path",
3875        ));
3876    }
3877    let file = page.files.pop().expect("exactly one file was checked");
3878    if !safe_store_rel_path(&file.path)
3879        || !file.path.ends_with(".md")
3880        || !(file.path.starts_with("records/") || file.path.starts_with("sources/"))
3881    {
3882        return Err(invalid_feed("v2 exact id proof has an invalid record path"));
3883    }
3884    verify_v2_file_proof(root, &file)?;
3885    Ok((
3886        file.path,
3887        V2BaselineFile {
3888            sha256: file.sha256,
3889            bytes: file.bytes,
3890            proof: Some(file.proof),
3891        },
3892    ))
3893}
3894
3895fn verify_v2_asset_proof(root: &str, item: &V2AssetManifestItem) -> LinkResult<()> {
3896    crate::linkmd_v2::normalize_path(&item.path)
3897        .map_err(|error| invalid_feed(error.to_string()))?;
3898    if !is_sha256(&item.blob_sha256)
3899        || !is_sha256(&item.leaf_hash)
3900        || item.wrappers.is_empty()
3901        || !matches!(item.disposition.as_str(), "hosted" | "withheld")
3902        || item
3903            .wrappers
3904            .iter()
3905            .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
3906    {
3907        return Err(invalid_feed("v2 asset manifest item is invalid"));
3908    }
3909    let leaf = json!({
3910        "blob_sha256": item.blob_sha256,
3911        "bytes": item.bytes,
3912        "disposition": item.disposition,
3913        "media_type": item.media_type,
3914        "path": item.path,
3915        "required": item.required,
3916        "v": 2,
3917        "wrappers": item.wrappers,
3918    });
3919    if crate::linkmd_v2::domain_hash("v2/asset-leaf", &leaf)
3920        .map_err(|error| invalid_feed(error.to_string()))?
3921        != item.leaf_hash
3922        || !crate::linkmd_v2::verify_proof_with_domain(
3923            root,
3924            &item.path,
3925            &item.proof,
3926            crate::linkmd_v2::ASSET_TREE_HASH_DOMAIN,
3927        )
3928        .map_err(|error| invalid_feed(error.to_string()))?
3929    {
3930        return Err(invalid_feed("v2 asset inclusion proof failed"));
3931    }
3932    match &item.proof {
3933        crate::linkmd_v2::HamtProof::Inclusion { entry, .. }
3934            if entry.name == item.path
3935                && entry.kind == crate::linkmd_v2::EntryKind::Blob
3936                && entry.child_hash == item.leaf_hash
3937                && entry.bytes == Some(item.bytes) =>
3938        {
3939            Ok(())
3940        }
3941        _ => Err(invalid_feed(
3942            "v2 asset proof leaf differs from its manifest",
3943        )),
3944    }
3945}
3946
3947fn v2_asset_manifest(
3948    cfg: &HubConfig,
3949    brain: &str,
3950    pointer: Option<&V2PointerBody>,
3951) -> LinkResult<std::collections::BTreeMap<String, V2BaselineAsset>> {
3952    let Some(pointer) = pointer else {
3953        return Ok(std::collections::BTreeMap::new());
3954    };
3955    let Some(root) = pointer.asset_root.as_deref() else {
3956        return Ok(std::collections::BTreeMap::new());
3957    };
3958    let mut assets = std::collections::BTreeMap::new();
3959    let mut after = String::new();
3960    loop {
3961        let encoded_after: String =
3962            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3963        let path = format!(
3964            "/api/hub/brains/{brain}/v2/assets?commit={}&limit=500&after={encoded_after}",
3965            pointer.commit_hash
3966        );
3967        let value = ensure_ok(
3968            request_capped(
3969                cfg,
3970                "GET",
3971                &path,
3972                None,
3973                Auth::Required,
3974                MAX_FEED_RESPONSE_BYTES,
3975            )?,
3976            "v2 asset manifest",
3977        )?;
3978        let page: V2AssetManifestPage = serde_json::from_value(value)
3979            .map_err(|_| invalid_feed("v2 asset manifest has an invalid shape"))?;
3980        if page.v != 2
3981            || page.commit != pointer.commit_hash
3982            || page.asset_root.as_deref() != Some(root)
3983            || page.assets.len() > 500
3984        {
3985            return Err(invalid_feed(
3986                "v2 asset manifest is not bound to the verified head",
3987            ));
3988        }
3989        for item in page.assets {
3990            verify_v2_asset_proof(root, &item)?;
3991            let path = item.path.clone();
3992            if assets
3993                .insert(
3994                    path,
3995                    V2BaselineAsset {
3996                        blob_sha256: item.blob_sha256,
3997                        bytes: item.bytes,
3998                        media_type: item.media_type,
3999                        wrappers: item.wrappers,
4000                        required: item.required,
4001                        disposition: item.disposition,
4002                        leaf_hash: item.leaf_hash,
4003                    },
4004                )
4005                .is_some()
4006            {
4007                return Err(invalid_feed("v2 asset manifest repeats a path"));
4008            }
4009            if assets.len() > MAX_PUSH_FILES {
4010                return Err(invalid_feed(
4011                    "v2 asset manifest exceeds the item-count bound",
4012                ));
4013            }
4014        }
4015        match page.next_cursor {
4016            None => break,
4017            Some(next) if next > after => after = next,
4018            Some(_) => return Err(invalid_feed("v2 asset manifest cursor did not advance")),
4019        }
4020    }
4021    Ok(assets)
4022}
4023
4024fn v2_asset_record(asset: &V2BaselineAsset, path: &str) -> crate::AssetRecord {
4025    crate::AssetRecord {
4026        path: path.to_string(),
4027        sha256: asset.blob_sha256.clone(),
4028        bytes: asset.bytes,
4029        media_type: asset.media_type.clone(),
4030        wrappers: asset.wrappers.clone(),
4031        required: asset.required,
4032    }
4033}
4034
4035fn v2_asset_resumes_hosting(
4036    remote: Option<&V2BaselineAsset>,
4037    path: &str,
4038    record: &crate::AssetRecord,
4039    disposition: &str,
4040) -> bool {
4041    remote.is_some_and(|asset| {
4042        asset.disposition == "withheld"
4043            && disposition == "hosted"
4044            && v2_asset_record(asset, path) == *record
4045    })
4046}
4047
4048fn v2_asset_record_manifest_bytes(
4049    assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
4050) -> LinkResult<Vec<u8>> {
4051    let mut bytes = Vec::new();
4052    for (path, asset) in assets {
4053        if asset.path != *path {
4054            return Err(invalid_feed(
4055                "local asset manifest key differs from its record path",
4056            ));
4057        }
4058        serde_json::to_writer(&mut bytes, asset)
4059            .map_err(|_| invalid_feed("could not materialize merged v2 assets.jsonl"))?;
4060        bytes.push(b'\n');
4061    }
4062    Ok(bytes)
4063}
4064
4065fn v2_local_asset_records(
4066    store: &Store,
4067) -> LinkResult<std::collections::BTreeMap<String, crate::AssetRecord>> {
4068    Ok(crate::assets::read_manifest(store)
4069        .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
4070        .into_iter()
4071        .map(|asset| (asset.path.clone(), asset))
4072        .collect())
4073}
4074
4075fn v2_asset_records_match_remote(
4076    local: &std::collections::BTreeMap<String, crate::AssetRecord>,
4077    remote: &std::collections::BTreeMap<String, V2BaselineAsset>,
4078) -> bool {
4079    local.len() == remote.len()
4080        && remote
4081            .iter()
4082            .all(|(path, asset)| local.get(path) == Some(&v2_asset_record(asset, path)))
4083}
4084
4085#[derive(Debug, Clone, PartialEq, Eq)]
4086struct V2PulledMerge<T> {
4087    records: std::collections::BTreeMap<String, T>,
4088    accept_remote: std::collections::BTreeSet<String>,
4089    conflicts: Vec<String>,
4090}
4091
4092/// Compute the local checkout that a pull may install without discarding a
4093/// local-only edit. Remote-only changes are accepted, identical concurrent
4094/// changes converge, and divergent concurrent changes are reported. The
4095/// caller can exempt kept-home coordinates from both remote installation and
4096/// conflict disclosure.
4097fn merge_v2_pulled_records<Base, Remote, Record, BaseRecord, RemoteRecord, KeepLocal>(
4098    base: &std::collections::BTreeMap<String, Base>,
4099    remote: &std::collections::BTreeMap<String, Remote>,
4100    local: &std::collections::BTreeMap<String, Record>,
4101    base_record: BaseRecord,
4102    remote_record: RemoteRecord,
4103    keep_local: KeepLocal,
4104) -> V2PulledMerge<Record>
4105where
4106    Record: Clone + Eq,
4107    BaseRecord: Fn(&Base, &str) -> Record,
4108    RemoteRecord: Fn(&Remote, &str) -> Record,
4109    KeepLocal: Fn(&str) -> bool,
4110{
4111    let paths = base
4112        .keys()
4113        .chain(remote.keys())
4114        .chain(local.keys())
4115        .cloned()
4116        .collect::<std::collections::BTreeSet<_>>();
4117    let mut records = local.clone();
4118    let mut accept_remote = std::collections::BTreeSet::new();
4119    let mut conflicts = Vec::new();
4120    for path in paths {
4121        if keep_local(&path) {
4122            continue;
4123        }
4124        let base_value = base.get(&path).map(|value| base_record(value, &path));
4125        let remote_value = remote.get(&path).map(|value| remote_record(value, &path));
4126        let local_value = local.get(&path).cloned();
4127        if local_value != base_value && remote_value != base_value && local_value != remote_value {
4128            conflicts.push(path);
4129            continue;
4130        }
4131        if local_value == base_value || local_value == remote_value {
4132            accept_remote.insert(path.clone());
4133            match remote_value {
4134                Some(value) => {
4135                    records.insert(path, value);
4136                }
4137                None => {
4138                    records.remove(&path);
4139                }
4140            }
4141        }
4142    }
4143    V2PulledMerge {
4144        records,
4145        accept_remote,
4146        conflicts,
4147    }
4148}
4149
4150fn sign_verified_v2_candidate(
4151    cfg: &HubConfig,
4152    head: &V2VerifiedHead,
4153    expected: &std::collections::BTreeMap<String, V2BaselineFile>,
4154    expected_assets: &std::collections::BTreeMap<String, V2BaselineAsset>,
4155    mutation_id: &str,
4156    request_body: &Value,
4157    challenge_value: &Value,
4158) -> LinkResult<(String, String, String)> {
4159    if head.view_kind != "full" {
4160        return Err(invalid_feed(
4161            "a scoped self-custody writer must use the proposal workflow",
4162        ));
4163    }
4164    if head.identity.custody != "self" {
4165        return Err(invalid_feed(
4166            "a hub-custodied brain unexpectedly requested an external signature",
4167        ));
4168    }
4169    let key = cfg
4170        .brain_key
4171        .as_ref()
4172        .ok_or_else(|| bad_agent_key("this self-custodied brain requires DBMD_BRAIN_KEY_FILE"))?;
4173    if key.multikey != format!("ed25519:{}", head.identity.fingerprint)
4174        || key.public_key_spki != head.identity.public_key_spki
4175    {
4176        return Err(bad_agent_key(
4177            "DBMD_BRAIN_KEY_FILE does not match the verified brain identity",
4178        ));
4179    }
4180    let challenge_id = challenge_value
4181        .get("id")
4182        .and_then(Value::as_str)
4183        .filter(|id| crate::ulid::is_ulid(id))
4184        .ok_or_else(|| invalid_feed("self-custody challenge has no canonical id"))?;
4185    let expected_endpoint = format!(
4186        "/api/hub/brains/{}/v2/signing-challenges/{challenge_id}",
4187        head.brain_id
4188    );
4189    if challenge_value
4190        .get("candidate_endpoint")
4191        .and_then(Value::as_str)
4192        != Some(expected_endpoint.as_str())
4193    {
4194        return Err(invalid_feed(
4195            "self-custody challenge candidate endpoint is not origin-bound",
4196        ));
4197    }
4198
4199    let mut files = std::collections::BTreeMap::new();
4200    let mut after = String::new();
4201    type CandidateCoordinate = (
4202        String,
4203        String,
4204        String,
4205        String,
4206        Option<String>,
4207        Option<String>,
4208        u64,
4209        Option<String>,
4210    );
4211    let mut pinned: Option<CandidateCoordinate> = None;
4212    loop {
4213        let encoded_after: String =
4214            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4215        let path = format!("{expected_endpoint}?limit=500&after={encoded_after}");
4216        let value = ensure_ok(
4217            request_capped(
4218                cfg,
4219                "GET",
4220                &path,
4221                None,
4222                Auth::Required,
4223                MAX_FEED_RESPONSE_BYTES,
4224            )?,
4225            "v2 self-custody candidate",
4226        )?;
4227        let page: V2SigningCandidatePage = serde_json::from_value(value)
4228            .map_err(|_| invalid_feed("self-custody candidate has an invalid shape"))?;
4229        if page.v != 2
4230            || page.challenge_id != challenge_id
4231            || page.mutation_id != mutation_id
4232            || page.candidate.seq != page.parent.seq + 1
4233            || page.files.len() > 500
4234            || page.expires_at.is_empty()
4235        {
4236            return Err(invalid_feed(
4237                "self-custody candidate is not bound to this mutation",
4238            ));
4239        }
4240        let coordinate = (
4241            page.request_hash.clone(),
4242            page.candidate.signing_bytes_base64.clone(),
4243            page.candidate.changes_base64.clone(),
4244            page.candidate.actor_claim_base64.clone(),
4245            page.candidate.content_root.clone(),
4246            page.candidate.asset_root.clone(),
4247            page.parent.seq,
4248            page.parent.commit_hash.clone(),
4249        );
4250        if pinned.as_ref().is_some_and(|prior| prior != &coordinate) {
4251            return Err(invalid_feed(
4252                "self-custody candidate changed between manifest pages",
4253            ));
4254        }
4255        pinned = Some(coordinate);
4256        let root = page
4257            .candidate
4258            .content_root
4259            .as_deref()
4260            .ok_or_else(|| invalid_feed("self-custody candidate has no content root"))?;
4261        for file in page.files {
4262            verify_v2_file_proof(root, &file)?;
4263            if files
4264                .insert(
4265                    file.path.clone(),
4266                    V2BaselineFile {
4267                        sha256: file.sha256,
4268                        bytes: file.bytes,
4269                        proof: Some(file.proof),
4270                    },
4271                )
4272                .is_some()
4273            {
4274                return Err(invalid_feed(
4275                    "self-custody candidate repeats a manifest path",
4276                ));
4277            }
4278            if files.len() > MAX_PUSH_FILES {
4279                return Err(invalid_feed(
4280                    "self-custody candidate exceeds the file-count bound",
4281                ));
4282            }
4283        }
4284        match page.next_cursor {
4285            None => break,
4286            Some(next) if next > after => after = next,
4287            Some(_) => {
4288                return Err(invalid_feed(
4289                    "self-custody candidate cursor did not advance",
4290                ))
4291            }
4292        }
4293    }
4294    if files.len() != expected.len()
4295        || files.iter().any(|(path, file)| {
4296            expected.get(path).is_none_or(|expected| {
4297                expected.sha256 != file.sha256 || expected.bytes != file.bytes
4298            })
4299        })
4300    {
4301        return Err(invalid_feed(
4302            "self-custody candidate contains an unexpected file mutation",
4303        ));
4304    }
4305    let mut assets = std::collections::BTreeMap::new();
4306    after.clear();
4307    loop {
4308        let encoded_after: String =
4309            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4310        let path = format!("{expected_endpoint}?kind=assets&limit=500&after={encoded_after}");
4311        let value = ensure_ok(
4312            request_capped(
4313                cfg,
4314                "GET",
4315                &path,
4316                None,
4317                Auth::Required,
4318                MAX_FEED_RESPONSE_BYTES,
4319            )?,
4320            "v2 self-custody asset candidate",
4321        )?;
4322        let page: V2SigningCandidatePage = serde_json::from_value(value)
4323            .map_err(|_| invalid_feed("self-custody asset candidate has an invalid shape"))?;
4324        let coordinate = (
4325            page.request_hash.clone(),
4326            page.candidate.signing_bytes_base64.clone(),
4327            page.candidate.changes_base64.clone(),
4328            page.candidate.actor_claim_base64.clone(),
4329            page.candidate.content_root.clone(),
4330            page.candidate.asset_root.clone(),
4331            page.parent.seq,
4332            page.parent.commit_hash.clone(),
4333        );
4334        if page.v != 2
4335            || page.challenge_id != challenge_id
4336            || page.mutation_id != mutation_id
4337            || page.assets.len() > 500
4338            || pinned.as_ref() != Some(&coordinate)
4339        {
4340            return Err(invalid_feed(
4341                "self-custody asset candidate changed or is not bound",
4342            ));
4343        }
4344        let root = page.candidate.asset_root.as_deref();
4345        if !page.assets.is_empty() && root.is_none() {
4346            return Err(invalid_feed("asset candidate has no asset root"));
4347        }
4348        for item in page.assets {
4349            verify_v2_asset_proof(root.expect("non-empty assets checked"), &item)?;
4350            if assets
4351                .insert(
4352                    item.path.clone(),
4353                    V2BaselineAsset {
4354                        blob_sha256: item.blob_sha256,
4355                        bytes: item.bytes,
4356                        media_type: item.media_type,
4357                        wrappers: item.wrappers,
4358                        required: item.required,
4359                        disposition: item.disposition,
4360                        leaf_hash: item.leaf_hash,
4361                    },
4362                )
4363                .is_some()
4364            {
4365                return Err(invalid_feed("self-custody candidate repeats an asset"));
4366            }
4367        }
4368        match page.next_cursor {
4369            None => break,
4370            Some(next) if next > after => after = next,
4371            Some(_) => {
4372                return Err(invalid_feed(
4373                    "self-custody asset candidate cursor did not advance",
4374                ))
4375            }
4376        }
4377    }
4378    if assets.len() != expected_assets.len()
4379        || assets.iter().any(|(path, asset)| {
4380            expected_assets.get(path).is_none_or(|expected| {
4381                asset.blob_sha256 != expected.blob_sha256
4382                    || asset.bytes != expected.bytes
4383                    || asset.media_type != expected.media_type
4384                    || asset.wrappers != expected.wrappers
4385                    || asset.required != expected.required
4386                    || asset.disposition != expected.disposition
4387            })
4388        })
4389    {
4390        return Err(invalid_feed(
4391            "self-custody candidate contains an unexpected asset mutation",
4392        ));
4393    }
4394    let Some((
4395        request_hash,
4396        signing_b64,
4397        changes_b64,
4398        actor_b64,
4399        root,
4400        asset_root,
4401        parent_seq,
4402        parent,
4403    )) = pinned
4404    else {
4405        return Err(invalid_feed("self-custody candidate has no manifest"));
4406    };
4407    let current_seq = head.pointer.as_ref().map_or(0, |pointer| pointer.seq);
4408    let current_commit = head
4409        .pointer
4410        .as_ref()
4411        .map(|pointer| pointer.commit_hash.clone());
4412    if parent_seq != current_seq || parent != current_commit {
4413        return Err(LinkError::RemoteAdvancedDuringSync);
4414    }
4415    let changes = STANDARD
4416        .decode(changes_b64)
4417        .map_err(|_| invalid_feed("self-custody changeset is not base64"))?;
4418    let mut expected_changes = json!({
4419        "mutation_id": mutation_id,
4420        "operations": request_body.get("operations").cloned().unwrap_or(Value::Null),
4421        "reason": request_body.get("reason").cloned().unwrap_or(Value::Null),
4422        "v": 2,
4423    });
4424    if let Some(withheld_links) = request_body.get("withheld_links") {
4425        expected_changes["withheld_links"] = withheld_links.clone();
4426    }
4427    if let Some(checkout_id) = request_body.get("checkout_id") {
4428        expected_changes["checkout_id"] = checkout_id.clone();
4429    }
4430    let expected_changes_bytes = crate::linkmd_v2::canonical_bytes(&expected_changes)
4431        .map_err(|error| invalid_feed(error.to_string()))?;
4432    if changes != expected_changes_bytes {
4433        return Err(invalid_feed(
4434            "self-custody changeset differs from the requested mutation",
4435        ));
4436    }
4437    let changes_hash = crate::linkmd_v2::domain_hash_bytes("v2/changeset", &changes)
4438        .map_err(|error| invalid_feed(error.to_string()))?;
4439    let request_value = json!({
4440        "base": request_body.get("base").cloned().unwrap_or(Value::Null),
4441        "brain": head.brain_id,
4442        "changes_sha256": changes_hash,
4443        "rebase": request_body.get("rebase").cloned().unwrap_or(Value::Null),
4444        "v": 2,
4445        "v1_bridge": Value::Null,
4446    });
4447    let expected_request_hash = crate::linkmd_v2::domain_hash("v2/request", &request_value)
4448        .map_err(|error| invalid_feed(error.to_string()))?;
4449    if request_hash != expected_request_hash {
4450        return Err(invalid_feed(
4451            "self-custody request hash differs from the requested mutation",
4452        ));
4453    }
4454    let actor = STANDARD
4455        .decode(actor_b64)
4456        .map_err(|_| invalid_feed("self-custody actor claim is not base64"))?;
4457    let actor_value: Value = serde_json::from_slice(&actor)
4458        .map_err(|_| invalid_feed("self-custody actor claim is not JSON"))?;
4459    if crate::linkmd_v2::canonical_bytes(&actor_value)
4460        .map_err(|error| invalid_feed(error.to_string()))?
4461        != actor
4462    {
4463        return Err(invalid_feed("self-custody actor claim is not canonical"));
4464    }
4465    let actor_object = actor_value
4466        .as_object()
4467        .ok_or_else(|| invalid_feed("self-custody actor claim is not an object"))?;
4468    let actor_claim = actor_object
4469        .get("claim")
4470        .ok_or_else(|| invalid_feed("self-custody actor claim body is missing"))?;
4471    let actor_public_key = actor_object
4472        .get("public_key")
4473        .and_then(Value::as_str)
4474        .ok_or_else(|| invalid_feed("self-custody actor signer is missing"))?;
4475    let actor_fingerprint = actor_object
4476        .get("fingerprint")
4477        .and_then(Value::as_str)
4478        .ok_or_else(|| invalid_feed("self-custody actor fingerprint is missing"))?;
4479    let actor_signature = actor_object
4480        .get("sig")
4481        .and_then(Value::as_str)
4482        .ok_or_else(|| invalid_feed("self-custody actor signature is missing"))?;
4483    let actor_message = crate::linkmd_v2::canonical_bytes(actor_claim)
4484        .map_err(|error| invalid_feed(error.to_string()))?;
4485    let actor_der = verify_v2_spki_signature(actor_public_key, &actor_message, actor_signature)?;
4486    let expected_actor_signer = format!("{actor_fingerprint}:{actor_public_key}");
4487    let expected_actor_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4488    let expected_actor_asset_root = asset_root.clone().map(Value::String).unwrap_or(Value::Null);
4489    let impact = actor_claim
4490        .get("result")
4491        .and_then(|result| result.get("impact"))
4492        .and_then(Value::as_object);
4493    let impact_fields = [
4494        "creates",
4495        "updates",
4496        "deletes",
4497        "withdrawals",
4498        "renames",
4499        "restores",
4500        "asset_changes",
4501        "public_expansions",
4502        "executable_activations",
4503    ];
4504    let impact_is_valid = impact.is_some_and(|impact| {
4505        impact.len() == impact_fields.len() + 1
4506            && impact.get("v").and_then(Value::as_u64) == Some(1)
4507            && impact_fields
4508                .iter()
4509                .all(|field| impact.get(*field).and_then(Value::as_u64).is_some())
4510    });
4511    if format!("{:x}", Sha256::digest(&actor_der)) != actor_fingerprint
4512        || head
4513            .trust
4514            .hub_signer
4515            .as_ref()
4516            .is_some_and(|known| known != &expected_actor_signer)
4517        || actor_claim.get("mutation_id").and_then(Value::as_str) != Some(mutation_id)
4518        || actor_claim.get("request_hash").and_then(Value::as_str) != Some(request_hash.as_str())
4519        || actor_claim
4520            .get("candidate")
4521            .and_then(|candidate| candidate.get("changes_sha256"))
4522            .and_then(Value::as_str)
4523            != Some(changes_hash.as_str())
4524        || actor_claim
4525            .get("candidate")
4526            .and_then(|candidate| candidate.get("state_root"))
4527            != Some(&expected_actor_root)
4528        || actor_claim
4529            .get("candidate")
4530            .and_then(|candidate| candidate.get("asset_root"))
4531            != Some(&expected_actor_asset_root)
4532        || actor_claim
4533            .get("candidate")
4534            .and_then(|candidate| candidate.get("control_revision"))
4535            .and_then(Value::as_str)
4536            != Some(head.control_revision.as_str())
4537        || !impact_is_valid
4538    {
4539        return Err(invalid_feed(
4540            "self-custody actor claim does not bind the verified authority",
4541        ));
4542    }
4543    let actor_hash = crate::linkmd_v2::domain_hash_bytes("v2/actor-claim", &actor)
4544        .map_err(|error| invalid_feed(error.to_string()))?;
4545    let signing = STANDARD
4546        .decode(signing_b64)
4547        .map_err(|_| invalid_feed("self-custody signing bytes are not base64"))?;
4548    let signing_value: Value = serde_json::from_slice(&signing)
4549        .map_err(|_| invalid_feed("self-custody signing bytes are not JSON"))?;
4550    if crate::linkmd_v2::canonical_bytes(&signing_value)
4551        .map_err(|error| invalid_feed(error.to_string()))?
4552        != signing
4553    {
4554        return Err(invalid_feed("self-custody signing bytes are not canonical"));
4555    }
4556    let pointer = head.pointer.as_ref();
4557    let expected_materializer = pointer
4558        .map(|value| value.materializer.as_str())
4559        .unwrap_or("dbmd-projection-v1");
4560    let expected_parent_commit = request_body
4561        .get("base")
4562        .and_then(|base| base.get("commit_hash"))
4563        .cloned()
4564        .unwrap_or(Value::Null);
4565    let expected_parent_root = request_body
4566        .get("base")
4567        .and_then(|base| base.get("content_root"))
4568        .cloned()
4569        .unwrap_or(Value::Null);
4570    let expected_state_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4571    let expected_parent_asset_root = request_body
4572        .get("base")
4573        .and_then(|base| base.get("asset_root"))
4574        .cloned()
4575        .unwrap_or(Value::Null);
4576    let expected_asset_root = asset_root.map(Value::String).unwrap_or(Value::Null);
4577    let expected_prev_entry = pointer
4578        .map(|value| Value::String(value.feed_hash.clone()))
4579        .unwrap_or(Value::Null);
4580    let expected_signer_epoch = u64::try_from(head.identity.previous.len())
4581        .map_err(|_| invalid_feed("brain identity history is too large"))?
4582        + 1;
4583    if signing_value.get("v").and_then(Value::as_u64) != Some(2)
4584        || signing_value.get("seq").and_then(Value::as_u64) != Some(current_seq + 1)
4585        || signing_value.get("signer_epoch").and_then(Value::as_u64) != Some(expected_signer_epoch)
4586        || signing_value.get("brain").and_then(Value::as_str) != Some(key.multikey.as_str())
4587        || signing_value.get("public_key").and_then(Value::as_str)
4588            != Some(key.public_key_spki.as_str())
4589        || signing_value.get("parent_commit") != Some(&expected_parent_commit)
4590        || signing_value.get("parent_root") != Some(&expected_parent_root)
4591        || signing_value.get("state_root") != Some(&expected_state_root)
4592        || signing_value.get("parent_asset_root") != Some(&expected_parent_asset_root)
4593        || signing_value.get("asset_root") != Some(&expected_asset_root)
4594        || signing_value.get("materializer").and_then(Value::as_str) != Some(expected_materializer)
4595        || signing_value.get("changes_sha256").and_then(Value::as_str)
4596            != Some(changes_hash.as_str())
4597        || signing_value.get("actor_ref").and_then(Value::as_str) != Some(actor_hash.as_str())
4598        || signing_value
4599            .get("control_revision")
4600            .and_then(Value::as_str)
4601            != Some(head.control_revision.as_str())
4602        || signing_value.get("prev_entry_hash") != Some(&expected_prev_entry)
4603        || signing_value.get("v1_bridge") != Some(&Value::Null)
4604        || signing_value.get("op").and_then(Value::as_str) != Some("changeset")
4605    {
4606        return Err(invalid_feed(
4607            "self-custody signing bytes do not bind the verified candidate",
4608        ));
4609    }
4610    let pair = agent_keypair(&key.pkcs8)?;
4611    let signature = URL_SAFE_NO_PAD.encode(pair.sign(&signing).as_ref());
4612    Ok((challenge_id.to_string(), signature, expected_actor_signer))
4613}
4614
4615fn v2_baseline_name(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<String> {
4616    let origin = normalized_origin(&cfg.hub)?;
4617    let absolute = if checkout.is_absolute() {
4618        checkout.to_path_buf()
4619    } else {
4620        std::env::current_dir()?.join(checkout)
4621    };
4622    Ok(format!(
4623        "sync-{}.json",
4624        content_sha256(format!("{origin}\0{brain}\0{}", absolute.display()).as_bytes())
4625    ))
4626}
4627
4628fn v2_checkout_id(existing: Option<&str>) -> LinkResult<String> {
4629    if let Some(value) = existing {
4630        if !is_sha256(value) {
4631            return Err(invalid_feed("v2 checkout pseudonym is invalid"));
4632        }
4633        return Ok(value.to_string());
4634    }
4635    use ring::rand::SecureRandom as _;
4636    let mut random = [0_u8; 32];
4637    ring::rand::SystemRandom::new()
4638        .fill(&mut random)
4639        .map_err(|_| invalid_feed("could not generate a checkout pseudonym"))?;
4640    Ok(random.iter().map(|byte| format!("{byte:02x}")).collect())
4641}
4642
4643#[cfg(any(unix, windows))]
4644fn lock_v2_sync_operation(cfg: &HubConfig, brain: &str) -> LinkResult<TrustLock> {
4645    let directory = open_trust_dir(cfg)?;
4646    let origin = normalized_origin(&cfg.hub)?;
4647    let name = format!(
4648        "operation-{}.lock",
4649        content_sha256(format!("{origin}\0{brain}").as_bytes())
4650    );
4651    lock_trust_name(&directory, &name)
4652}
4653
4654#[cfg(not(any(unix, windows)))]
4655fn lock_v2_sync_operation(_cfg: &HubConfig, _brain: &str) -> LinkResult<()> {
4656    Err(LinkError::UnsupportedPlatform {
4657        operation: "serialized link.md v2 sync",
4658    })
4659}
4660
4661fn same_v2_head(left: &V2VerifiedHead, right: &V2VerifiedHead) -> bool {
4662    left.brain_id == right.brain_id
4663        && left.view_kind == right.view_kind
4664        && left.view_revision == right.view_revision
4665        && left.control_revision == right.control_revision
4666        && match (&left.pointer, &right.pointer) {
4667            (None, None) => true,
4668            (Some(left), Some(right)) => {
4669                left.seq == right.seq
4670                    && left.commit_hash == right.commit_hash
4671                    && left.content_root == right.content_root
4672                    && left.asset_root == right.asset_root
4673                    && left.feed_hash == right.feed_hash
4674            }
4675            _ => false,
4676        }
4677}
4678
4679fn scoped_projection_bytes(brain: &str) -> Vec<u8> {
4680    format!(
4681        "---\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"
4682    )
4683    .into_bytes()
4684}
4685
4686fn scoped_projection_sha256(brain: &str) -> String {
4687    content_sha256(&scoped_projection_bytes(brain))
4688}
4689
4690#[derive(Deserialize)]
4691struct LocalScopedViewMarker {
4692    v: u8,
4693    kind: String,
4694    authoritative: bool,
4695    brain: String,
4696    projection_sha256: String,
4697}
4698
4699/// True only when this store carries the exact generated marker for a local
4700/// link.md scoped view. This is presentation context, never authorization;
4701/// the hub remains the only authority for reads and writes.
4702pub fn has_verified_local_scoped_view(store: &Store) -> bool {
4703    let marker = store
4704        .read_bounded(Path::new(".dbmd/view.json"), 64 * 1024)
4705        .ok()
4706        .and_then(|bytes| serde_json::from_slice::<LocalScopedViewMarker>(&bytes).ok());
4707    let Some(marker) = marker else {
4708        return false;
4709    };
4710    if marker.v != 1
4711        || marker.kind != "link.md-scoped-view"
4712        || marker.authoritative
4713        || !crate::ulid::is_ulid(&marker.brain)
4714        || marker.projection_sha256 != scoped_projection_sha256(&marker.brain)
4715    {
4716        return false;
4717    }
4718    store
4719        .read_bounded(Path::new("DB.md"), crate::parser::MAX_DBMD_FILE_BYTES)
4720        .is_ok_and(|bytes| content_sha256(&bytes) == marker.projection_sha256)
4721}
4722
4723fn scoped_view_metadata(head: &V2VerifiedHead, files: usize) -> LinkResult<Vec<u8>> {
4724    let mut bytes = serde_json::to_vec_pretty(&json!({
4725        "v": 1,
4726        "kind": "link.md-scoped-view",
4727        "authoritative": false,
4728        "brain": head.brain_id,
4729        "view_revision": head.view_revision,
4730        "head_seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
4731        "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
4732        "content_root": head.pointer.as_ref().and_then(|pointer| pointer.content_root.as_ref()),
4733        "visible_files": files,
4734        "projection_sha256": scoped_projection_sha256(&head.brain_id),
4735    }))
4736    .map_err(|_| invalid_feed("could not serialize scoped view metadata"))?;
4737    bytes.push(b'\n');
4738    Ok(bytes)
4739}
4740
4741fn refresh_scoped_view_marker(
4742    store: &Store,
4743    head: &V2VerifiedHead,
4744    files: usize,
4745) -> LinkResult<()> {
4746    if head.view_kind == "scoped" {
4747        store.write_atomic(
4748            Path::new(".dbmd/view.json"),
4749            &scoped_view_metadata(head, files)?,
4750        )?;
4751    }
4752    Ok(())
4753}
4754
4755fn ensure_v2_view_compatible(
4756    head: &V2VerifiedHead,
4757    baseline: Option<&V2SyncBaseline>,
4758) -> LinkResult<()> {
4759    let Some(baseline) = baseline else {
4760        return Ok(());
4761    };
4762    match (
4763        baseline.view_kind.as_deref(),
4764        baseline.view_revision.as_deref(),
4765    ) {
4766        (None, None) if head.view_kind == "full" => Ok(()),
4767        (Some(kind), Some(revision))
4768            if kind == head.view_kind && revision == head.view_revision =>
4769        {
4770            Ok(())
4771        }
4772        _ => Err(LinkError::ScopedViewChanged),
4773    }
4774}
4775
4776fn ensure_established_v2_checkout_opened(
4777    head: &V2VerifiedHead,
4778    baseline: Option<&V2SyncBaseline>,
4779    opened: bool,
4780) -> LinkResult<()> {
4781    if baseline.is_none() || opened {
4782        return Ok(());
4783    }
4784    if head.view_kind == "scoped" {
4785        return Err(LinkError::ScopedProjectionModified);
4786    }
4787    Err(LinkError::InvalidPack {
4788        message: "the established v2 checkout is no longer a valid db.md store; repair its DB.md before syncing".to_string(),
4789    })
4790}
4791
4792fn remove_scoped_projection(
4793    head: &V2VerifiedHead,
4794    baseline: Option<&V2SyncBaseline>,
4795    view: &mut V2LocalView,
4796) -> LinkResult<()> {
4797    if head.view_kind != "scoped" {
4798        return Ok(());
4799    }
4800    let expected = scoped_projection_sha256(&head.brain_id);
4801    if baseline
4802        .and_then(|state| state.projection_sha256.as_deref())
4803        .is_some_and(|pinned| pinned != expected)
4804    {
4805        return Err(LinkError::ScopedViewChanged);
4806    }
4807    if view.riding.get("DB.md").map(|(sha256, _)| sha256.as_str()) != Some(expected.as_str()) {
4808        return Err(LinkError::ScopedProjectionModified);
4809    }
4810    view.riding.remove("DB.md");
4811    view.eligibility.remove("DB.md");
4812    Ok(())
4813}
4814
4815fn local_view_for_v2_push(
4816    store: &Store,
4817    head: &V2VerifiedHead,
4818    baseline: Option<&V2SyncBaseline>,
4819    carried: Option<V2LocalView>,
4820) -> LinkResult<V2LocalView> {
4821    match carried {
4822        // The pull half already verified and removed the generated scoped
4823        // projection before constructing its in-memory handoff. Requiring it
4824        // a second time would turn every scoped bidirectional write into a
4825        // false tamper refusal.
4826        Some(view) => Ok(view),
4827        None => {
4828            let mut view = v2_local_files(store)?;
4829            remove_scoped_projection(head, baseline, &mut view)?;
4830            Ok(view)
4831        }
4832    }
4833}
4834
4835fn files_for_v2_view(
4836    head: &V2VerifiedHead,
4837    mut files: std::collections::BTreeMap<String, V2BaselineFile>,
4838) -> std::collections::BTreeMap<String, V2BaselineFile> {
4839    if head.view_kind == "scoped" {
4840        // Even when a grant explicitly includes canonical DB.md, a partial
4841        // checkout uses a generated marker. Canonical validation remains a
4842        // server-side operation over the complete brain.
4843        files.remove("DB.md");
4844    }
4845    files
4846}
4847
4848fn parse_v2_baseline(cfg: &HubConfig, brain: &str, bytes: &[u8]) -> LinkResult<V2SyncBaseline> {
4849    let baseline: V2SyncBaseline =
4850        serde_json::from_slice(bytes).map_err(|_| invalid_feed("v2 sync baseline is corrupt"))?;
4851    if baseline.v != 2
4852        || baseline.origin != normalized_origin(&cfg.hub)?
4853        || baseline.brain != brain
4854        || baseline
4855            .commit_hash
4856            .as_deref()
4857            .is_some_and(|hash| !is_sha256(hash))
4858        || baseline
4859            .content_root
4860            .as_deref()
4861            .is_some_and(|hash| !is_sha256(hash))
4862        || baseline
4863            .asset_root
4864            .as_deref()
4865            .is_some_and(|hash| !is_sha256(hash))
4866        || baseline
4867            .local_policy_digest
4868            .as_deref()
4869            .is_some_and(|hash| !is_sha256(hash))
4870        || baseline
4871            .view_kind
4872            .as_deref()
4873            .is_some_and(|kind| !matches!(kind, "full" | "scoped"))
4874        || baseline
4875            .view_revision
4876            .as_deref()
4877            .is_some_and(|hash| !is_sha256(hash))
4878        || baseline
4879            .projection_sha256
4880            .as_deref()
4881            .is_some_and(|hash| !is_sha256(hash))
4882        || (baseline.view_kind.as_deref() == Some("scoped")
4883            && (baseline.view_revision.is_none() || baseline.projection_sha256.is_none()))
4884        || baseline.files.len() > MAX_PUSH_FILES
4885        || baseline.assets.len() > MAX_PUSH_FILES
4886        || baseline.local_eligibility.len() > MAX_PUSH_FILES
4887        || baseline.remote_copy_remains.len() > MAX_PUSH_FILES
4888        || baseline.files.iter().any(|(path, file)| {
4889            crate::linkmd_v2::normalize_path(path).is_err()
4890                || !is_sha256(&file.sha256)
4891                || file.bytes > MAX_STORE_BYTES
4892        })
4893        || baseline.assets.iter().any(|(path, asset)| {
4894            crate::linkmd_v2::normalize_path(path).is_err()
4895                || !is_sha256(&asset.blob_sha256)
4896                || !is_sha256(&asset.leaf_hash)
4897                || asset.bytes > MAX_STORE_BYTES
4898                || !matches!(asset.disposition.as_str(), "hosted" | "withheld")
4899                || asset.wrappers.is_empty()
4900                || asset
4901                    .wrappers
4902                    .iter()
4903                    .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
4904        })
4905        || baseline
4906            .local_eligibility
4907            .keys()
4908            .chain(baseline.remote_copy_remains.keys())
4909            .any(|path| crate::linkmd_v2::normalize_path(path).is_err())
4910        || baseline
4911            .remote_copy_remains
4912            .values()
4913            .any(|hash| !is_sha256(hash))
4914        || baseline
4915            .checkout_id
4916            .as_deref()
4917            .is_some_and(|checkout_id| !is_sha256(checkout_id))
4918    {
4919        return Err(invalid_feed("v2 sync baseline failed validation"));
4920    }
4921    Ok(baseline)
4922}
4923
4924#[cfg(unix)]
4925fn load_v2_baseline(
4926    cfg: &HubConfig,
4927    brain: &str,
4928    checkout: &Path,
4929) -> LinkResult<Option<V2SyncBaseline>> {
4930    use std::os::fd::{AsRawFd as _, FromRawFd as _};
4931    let directory = open_trust_dir(cfg)?;
4932    let name_string = v2_baseline_name(cfg, brain, checkout)?;
4933    let _lock = lock_trust_name(&directory, &name_string)?;
4934    let name = c_name(name_string.as_bytes(), &name_string)?;
4935    let fd = unsafe {
4936        libc::openat(
4937            directory.as_raw_fd(),
4938            name.as_ptr(),
4939            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4940        )
4941    };
4942    if fd < 0 {
4943        let error = std::io::Error::last_os_error();
4944        return if error.kind() == std::io::ErrorKind::NotFound {
4945            Ok(None)
4946        } else {
4947            Err(LinkError::UnsafePath { path: name_string })
4948        };
4949    }
4950    let file = unsafe { std::fs::File::from_raw_fd(fd) };
4951    let mut bytes = Vec::new();
4952    file.take(MAX_FEED_RESPONSE_BYTES + 1)
4953        .read_to_end(&mut bytes)?;
4954    if bytes.len() as u64 > MAX_FEED_RESPONSE_BYTES {
4955        return Err(invalid_feed("v2 sync baseline is oversized"));
4956    }
4957    Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?))
4958}
4959
4960#[cfg(windows)]
4961fn load_v2_baseline(
4962    cfg: &HubConfig,
4963    brain: &str,
4964    checkout: &Path,
4965) -> LinkResult<Option<V2SyncBaseline>> {
4966    let directory = open_trust_dir(cfg)?;
4967    let name = v2_baseline_name(cfg, brain, checkout)?;
4968    let _lock = lock_trust_name(&directory, &name)?;
4969    let mut reader = crate::fsx::BoundedDirReader::from_root(&directory)?;
4970    match reader.read(Path::new(&name), MAX_FEED_RESPONSE_BYTES) {
4971        Ok(bytes) => Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?)),
4972        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
4973        Err(_) => Err(LinkError::UnsafePath { path: name }),
4974    }
4975}
4976
4977#[cfg(not(any(unix, windows)))]
4978fn load_v2_baseline(
4979    _cfg: &HubConfig,
4980    _brain: &str,
4981    _checkout: &Path,
4982) -> LinkResult<Option<V2SyncBaseline>> {
4983    Err(LinkError::UnsupportedPlatform {
4984        operation: "verified link.md v2 baseline",
4985    })
4986}
4987
4988#[cfg(unix)]
4989fn save_v2_baseline(
4990    cfg: &HubConfig,
4991    brain: &str,
4992    checkout: &Path,
4993    baseline: &V2SyncBaseline,
4994) -> LinkResult<()> {
4995    use std::os::fd::{AsRawFd as _, FromRawFd as _};
4996    let directory = open_trust_dir(cfg)?;
4997    let name_string = v2_baseline_name(cfg, brain, checkout)?;
4998    let _lock = lock_trust_name(&directory, &name_string)?;
4999    let name = c_name(name_string.as_bytes(), &name_string)?;
5000    let mut bytes = serde_json::to_vec(baseline)
5001        .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5002    bytes.push(b'\n');
5003    let temp_string = format!(
5004        ".{name_string}.tmp.{}-{}",
5005        std::process::id(),
5006        std::time::SystemTime::now()
5007            .duration_since(std::time::UNIX_EPOCH)
5008            .unwrap_or_default()
5009            .as_nanos()
5010    );
5011    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5012    let fd = unsafe {
5013        libc::openat(
5014            directory.as_raw_fd(),
5015            temp.as_ptr(),
5016            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5017            0o600,
5018        )
5019    };
5020    if fd < 0 {
5021        return Err(std::io::Error::last_os_error().into());
5022    }
5023    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
5024    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
5025        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5026        return Err(error.into());
5027    }
5028    drop(file);
5029    if unsafe {
5030        libc::renameat(
5031            directory.as_raw_fd(),
5032            temp.as_ptr(),
5033            directory.as_raw_fd(),
5034            name.as_ptr(),
5035        )
5036    } != 0
5037    {
5038        let error = std::io::Error::last_os_error();
5039        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5040        return Err(error.into());
5041    }
5042    directory.sync_all()?;
5043    Ok(())
5044}
5045
5046#[cfg(windows)]
5047fn save_v2_baseline(
5048    cfg: &HubConfig,
5049    brain: &str,
5050    checkout: &Path,
5051    baseline: &V2SyncBaseline,
5052) -> LinkResult<()> {
5053    let directory = open_trust_dir(cfg)?;
5054    let name = v2_baseline_name(cfg, brain, checkout)?;
5055    let _lock = lock_trust_name(&directory, &name)?;
5056    let mut bytes = serde_json::to_vec(baseline)
5057        .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5058    bytes.push(b'\n');
5059    crate::fsx::write_atomic_beneath(&directory, Path::new(&name), &bytes, false, true)?;
5060    Ok(())
5061}
5062
5063#[cfg(not(any(unix, windows)))]
5064fn save_v2_baseline(
5065    _cfg: &HubConfig,
5066    _brain: &str,
5067    _checkout: &Path,
5068    _baseline: &V2SyncBaseline,
5069) -> LinkResult<()> {
5070    Err(LinkError::UnsupportedPlatform {
5071        operation: "verified link.md v2 baseline",
5072    })
5073}
5074
5075fn v2_baseline_from_head(
5076    cfg: &HubConfig,
5077    head: &V2VerifiedHead,
5078    files: std::collections::BTreeMap<String, V2BaselineFile>,
5079    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
5080    local: Option<&V2LocalView>,
5081    checkout_id: Option<&str>,
5082) -> LinkResult<V2SyncBaseline> {
5083    let mut local_eligibility = local
5084        .map(|view| view.eligibility.clone())
5085        .unwrap_or_default();
5086    if let Some(view) = local {
5087        for path in files.keys() {
5088            local_eligibility
5089                .entry(path.clone())
5090                .or_insert_with(|| !view.policy.keeps_home(path));
5091        }
5092    }
5093    let remote_copy_remains = local_eligibility
5094        .iter()
5095        .filter(|(_, riding)| !**riding)
5096        .filter_map(|(path, _)| {
5097            files
5098                .get(path)
5099                .map(|file| (path.clone(), file.sha256.clone()))
5100        })
5101        .collect();
5102    Ok(V2SyncBaseline {
5103        v: 2,
5104        origin: normalized_origin(&cfg.hub)?,
5105        brain: head.brain_id.clone(),
5106        checkout_id: Some(v2_checkout_id(checkout_id)?),
5107        head_seq: Some(head.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
5108        commit_hash: head
5109            .pointer
5110            .as_ref()
5111            .map(|pointer| pointer.commit_hash.clone()),
5112        content_root: head
5113            .pointer
5114            .as_ref()
5115            .and_then(|pointer| pointer.content_root.clone()),
5116        asset_root: head
5117            .pointer
5118            .as_ref()
5119            .and_then(|pointer| pointer.asset_root.clone()),
5120        assets,
5121        view_kind: Some(head.view_kind.clone()),
5122        view_revision: Some(head.view_revision.clone()),
5123        projection_sha256: (head.view_kind == "scoped")
5124            .then(|| scoped_projection_sha256(&head.brain_id)),
5125        files,
5126        local_policy_digest: local.map(|view| view.policy.digest.clone()),
5127        local_eligibility,
5128        remote_copy_remains,
5129    })
5130}
5131
5132fn v2_local_files(store: &Store) -> LinkResult<V2LocalView> {
5133    let policy = crate::linkmd_sync_policy::load(store)
5134        .map_err(|message| LinkError::InvalidPack { message })?;
5135    let asset_paths = crate::assets::read_manifest(store)
5136        .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
5137        .into_iter()
5138        .map(|asset| asset.path)
5139        .collect::<std::collections::BTreeSet<_>>();
5140    let mut result = std::collections::BTreeMap::new();
5141    let mut eligibility = std::collections::BTreeMap::new();
5142    let mut riding_links = Vec::<(String, Vec<String>)>::new();
5143    let mut total = 0_u64;
5144    let mut paths = vec![PathBuf::from("DB.md")];
5145    paths.extend(store.walk()?);
5146    for relative in paths {
5147        let path = relative.to_string_lossy().replace('\\', "/");
5148        // v2 catalogs and asset inventory are materialized/signed separately.
5149        if matches!(path.as_str(), "assets.jsonl" | "index.md" | "index.jsonl") {
5150            continue;
5151        }
5152        if asset_paths.contains(&path) {
5153            continue;
5154        }
5155        crate::linkmd_v2::normalize_path(&path).map_err(|error| LinkError::UnsafePath {
5156            path: error.to_string(),
5157        })?;
5158        let riding = !policy.keeps_home(&path);
5159        eligibility.insert(path.clone(), riding);
5160        if !riding {
5161            continue;
5162        }
5163        let remaining = MAX_STORE_BYTES.saturating_sub(total);
5164        let bytes = store.read_bounded(&relative, remaining)?;
5165        total = total
5166            .checked_add(bytes.len() as u64)
5167            .ok_or_else(|| LinkError::PushTooLarge {
5168                detail: "v2 local byte count overflow".to_string(),
5169            })?;
5170        if total > MAX_STORE_BYTES {
5171            return Err(LinkError::PushTooLarge {
5172                detail: format!("{total} uncompressed bytes"),
5173            });
5174        }
5175        if std::str::from_utf8(&bytes).is_err() {
5176            return Err(LinkError::NotUtf8 { path });
5177        }
5178        let text = std::str::from_utf8(&bytes).expect("UTF-8 was checked");
5179        riding_links.push((path.clone(), crate::store::extract_edge_targets(text)));
5180        result.insert(path, (content_sha256(&bytes), bytes.len() as u64));
5181    }
5182    let kept_home = eligibility
5183        .iter()
5184        .filter(|(_, riding)| !**riding)
5185        .map(|(path, _)| path.clone())
5186        .collect::<std::collections::BTreeSet<_>>();
5187    let mut withheld_links = riding_links
5188        .into_iter()
5189        .flat_map(|(source, targets)| {
5190            let kept_home = &kept_home;
5191            let policy = &policy;
5192            targets.into_iter().filter_map(move |target| {
5193                let target = format!("{target}.md");
5194                // A kept-home target need not be a file on THIS machine. An
5195                // export omits kept-home content by construction, so a store
5196                // restored from one links to names it does not carry — and
5197                // without this the owner's own export could never be pushed
5198                // back, which is the opposite of owning your data. The policy
5199                // is the owner's statement about where a path lives, and it
5200                // stands whether or not this checkout holds a copy. It
5201                // discloses nothing either way: the name is already written in
5202                // the content that links to it.
5203                (kept_home.contains(&target) || policy.keeps_home(&target)).then_some(
5204                    V2WithheldLink {
5205                        source: source.clone(),
5206                        target,
5207                    },
5208                )
5209            })
5210        })
5211        .collect::<Vec<_>>();
5212    withheld_links.sort();
5213    withheld_links.dedup();
5214    Ok(V2LocalView {
5215        riding: result,
5216        eligibility,
5217        policy,
5218        withheld_links,
5219    })
5220}
5221
5222#[derive(Debug, Deserialize)]
5223struct V2DownloadItem {
5224    path: String,
5225    sha256: String,
5226    bytes: u64,
5227    url: String,
5228    method: String,
5229}
5230
5231#[derive(Debug, Deserialize)]
5232struct V2DownloadWindow {
5233    v: u8,
5234    commit: String,
5235    downloads: Vec<V2DownloadItem>,
5236}
5237
5238#[derive(Debug, Deserialize)]
5239struct V2BulkStreamHeader {
5240    v: u8,
5241    path: String,
5242    sha256: String,
5243    bytes: u64,
5244}
5245
5246fn parse_v2_bulk_stream(
5247    bytes: &[u8],
5248    expected: &[(&String, &V2BaselineFile)],
5249) -> LinkResult<Vec<(String, Vec<u8>)>> {
5250    if !bytes.starts_with(V2_BULK_STREAM_MAGIC) {
5251        return Err(invalid_feed("v2 bulk stream has an invalid magic"));
5252    }
5253    let mut cursor = V2_BULK_STREAM_MAGIC.len();
5254    let mut result = Vec::with_capacity(expected.len());
5255    for (expected_path, expected_file) in expected {
5256        let length_bytes = bytes
5257            .get(cursor..cursor + 4)
5258            .ok_or_else(|| invalid_feed("v2 bulk stream ended before a frame header"))?;
5259        cursor += 4;
5260        let header_len = u32::from_be_bytes(length_bytes.try_into().expect("four bytes")) as usize;
5261        if header_len == 0 || header_len > 4 * 1024 {
5262            return Err(invalid_feed("v2 bulk stream has an invalid frame length"));
5263        }
5264        let header_bytes = bytes
5265            .get(cursor..cursor + header_len)
5266            .ok_or_else(|| invalid_feed("v2 bulk stream ended inside a frame header"))?;
5267        cursor += header_len;
5268        let header: V2BulkStreamHeader = serde_json::from_slice(header_bytes)
5269            .map_err(|_| invalid_feed("v2 bulk stream has an invalid frame header"))?;
5270        if header.v != 2
5271            || &header.path != *expected_path
5272            || header.sha256 != expected_file.sha256
5273            || header.bytes != expected_file.bytes
5274            || header.bytes > V2_BULK_STREAM_CONTENT_BYTES
5275        {
5276            return Err(invalid_feed(
5277                "v2 bulk stream frame differs from its proven manifest entry",
5278            ));
5279        }
5280        let body_len = usize::try_from(header.bytes)
5281            .map_err(|_| invalid_feed("v2 bulk stream frame length overflows this platform"))?;
5282        let body = bytes
5283            .get(cursor..cursor + body_len)
5284            .ok_or_else(|| invalid_feed("v2 bulk stream ended inside file bytes"))?;
5285        cursor += body_len;
5286        if content_sha256(body) != header.sha256 {
5287            return Err(invalid_feed(
5288                "v2 bulk stream file differs from its proven manifest entry",
5289            ));
5290        }
5291        result.push((header.path, body.to_vec()));
5292    }
5293    if bytes.get(cursor..cursor + 4) != Some(&[0, 0, 0, 0]) {
5294        return Err(invalid_feed("v2 bulk stream has no exact end marker"));
5295    }
5296    cursor += 4;
5297    if cursor != bytes.len() {
5298        return Err(invalid_feed("v2 bulk stream carries trailing data"));
5299    }
5300    Ok(result)
5301}
5302
5303fn download_v2_bulk_stream(
5304    cfg: &HubConfig,
5305    brain: &str,
5306    pointer: &V2PointerBody,
5307    pending: &[(&String, &V2BaselineFile)],
5308) -> LinkResult<Vec<(String, Vec<u8>)>> {
5309    let claims = pending
5310        .iter()
5311        .map(|(path, file)| {
5312            Ok(json!({
5313                "path": path,
5314                "sha256": file.sha256,
5315                "bytes": file.bytes,
5316                "proof": file.proof.as_ref().ok_or_else(|| {
5317                    invalid_feed("v2 manifest omitted a bulk-stream proof")
5318                })?,
5319            }))
5320        })
5321        .collect::<LinkResult<Vec<_>>>()?;
5322    let raw = request_raw(
5323        cfg,
5324        "POST",
5325        &format!("/api/hub/brains/{brain}/v2/stream"),
5326        Some(&json!({
5327            "commit": pointer.commit_hash,
5328            "files": claims,
5329        })),
5330        Auth::Required,
5331        V2_BULK_STREAM_RESPONSE_BYTES,
5332    )?;
5333    let body = ensure_raw_ok(raw, "download v2 bulk stream")?;
5334    parse_v2_bulk_stream(&body, pending)
5335}
5336
5337fn prepare_v2_downloads(
5338    cfg: &HubConfig,
5339    brain: &str,
5340    pointer: &V2PointerBody,
5341    pending: &[(&String, &V2BaselineFile)],
5342) -> LinkResult<Vec<V2DownloadItem>> {
5343    let mut result = Vec::with_capacity(pending.len());
5344    for chunk in pending.chunks(128) {
5345        let claims = chunk
5346            .iter()
5347            .map(|(path, file)| {
5348                Ok(json!({
5349                    "path": path,
5350                    "sha256": file.sha256,
5351                    "bytes": file.bytes,
5352                    "proof": file.proof.as_ref().ok_or_else(|| {
5353                        invalid_feed("v2 manifest omitted a download proof")
5354                    })?,
5355                }))
5356            })
5357            .collect::<LinkResult<Vec<_>>>()?;
5358        let value = ensure_ok(
5359            request_capped(
5360                cfg,
5361                "POST",
5362                &format!("/api/hub/brains/{brain}/v2/downloads"),
5363                Some(&json!({
5364                    "commit": pointer.commit_hash,
5365                    "files": claims,
5366                })),
5367                Auth::Required,
5368                MAX_FEED_RESPONSE_BYTES,
5369            )?,
5370            "prepare v2 blob downloads",
5371        )?;
5372        let window: V2DownloadWindow = serde_json::from_value(value)
5373            .map_err(|_| invalid_feed("v2 download window has an invalid shape"))?;
5374        if window.v != 2
5375            || window.commit != pointer.commit_hash
5376            || window.downloads.len() != chunk.len()
5377        {
5378            return Err(invalid_feed(
5379                "v2 download window is not bound to the requested files",
5380            ));
5381        }
5382        let mut by_path = window
5383            .downloads
5384            .into_iter()
5385            .map(|item| (item.path.clone(), item))
5386            .collect::<std::collections::BTreeMap<_, _>>();
5387        if by_path.len() != chunk.len() {
5388            return Err(invalid_feed("v2 download window repeats a path"));
5389        }
5390        for (path, file) in chunk {
5391            let item = by_path
5392                .remove(*path)
5393                .ok_or_else(|| invalid_feed("v2 download window omitted a path"))?;
5394            if item.method != "GET"
5395                || item.sha256 != file.sha256
5396                || item.bytes != file.bytes
5397                || item.url.is_empty()
5398            {
5399                return Err(invalid_feed(
5400                    "v2 download capability differs from its proven file",
5401                ));
5402            }
5403            result.push(item);
5404        }
5405    }
5406    Ok(result)
5407}
5408
5409fn prepare_v2_asset_downloads(
5410    cfg: &HubConfig,
5411    brain: &str,
5412    pointer: &V2PointerBody,
5413    pending: &[(&String, &V2BaselineAsset)],
5414) -> LinkResult<Vec<V2DownloadItem>> {
5415    let mut result = Vec::with_capacity(pending.len());
5416    for chunk in pending.chunks(128) {
5417        let claims = chunk
5418            .iter()
5419            .map(|(path, asset)| {
5420                json!({
5421                    "path": path,
5422                    "sha256": asset.blob_sha256,
5423                    "bytes": asset.bytes,
5424                    "leaf_hash": asset.leaf_hash,
5425                })
5426            })
5427            .collect::<Vec<_>>();
5428        let value = ensure_ok(
5429            request_capped(
5430                cfg,
5431                "POST",
5432                &format!("/api/hub/brains/{brain}/v2/assets/downloads"),
5433                Some(&json!({
5434                    "commit": pointer.commit_hash,
5435                    "assets": claims,
5436                })),
5437                Auth::Required,
5438                MAX_FEED_RESPONSE_BYTES,
5439            )?,
5440            "prepare v2 asset downloads",
5441        )?;
5442        let window: V2DownloadWindow = serde_json::from_value(value)
5443            .map_err(|_| invalid_feed("v2 asset download window has an invalid shape"))?;
5444        if window.v != 2
5445            || window.commit != pointer.commit_hash
5446            || window.downloads.len() != chunk.len()
5447        {
5448            return Err(invalid_feed(
5449                "v2 asset download window is not bound to the requested assets",
5450            ));
5451        }
5452        let mut by_path = window
5453            .downloads
5454            .into_iter()
5455            .map(|item| (item.path.clone(), item))
5456            .collect::<std::collections::BTreeMap<_, _>>();
5457        if by_path.len() != chunk.len() {
5458            return Err(invalid_feed("v2 asset download window repeats a path"));
5459        }
5460        for (path, asset) in chunk {
5461            let item = by_path
5462                .remove(*path)
5463                .ok_or_else(|| invalid_feed("v2 asset download window omitted a path"))?;
5464            if item.method != "GET"
5465                || item.sha256 != asset.blob_sha256
5466                || item.bytes != asset.bytes
5467                || item.url.is_empty()
5468            {
5469                return Err(invalid_feed(
5470                    "v2 asset download capability differs from its signed leaf",
5471                ));
5472            }
5473            result.push(item);
5474        }
5475    }
5476    Ok(result)
5477}
5478
5479fn download_v2_blob(cfg: &HubConfig, item: &V2DownloadItem) -> LinkResult<Vec<u8>> {
5480    let bytes = get_presigned(cfg, &item.url)?;
5481    if bytes.len() as u64 != item.bytes || content_sha256(&bytes) != item.sha256 {
5482        return Err(invalid_feed("v2 blob differs from its proven path entry"));
5483    }
5484    Ok(bytes)
5485}
5486
5487#[derive(Debug, Clone)]
5488struct V2StagedFile {
5489    path: String,
5490    source: PathBuf,
5491    sha256: String,
5492    bytes: u64,
5493}
5494
5495#[cfg(unix)]
5496fn v2_download_cache_dir(
5497    cfg: &HubConfig,
5498    brain: &str,
5499    pointer: &V2PointerBody,
5500) -> LinkResult<PathBuf> {
5501    v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
5502}
5503
5504#[cfg(unix)]
5505fn v2_download_cache_dir_for(
5506    cfg: &HubConfig,
5507    brain: &str,
5508    transaction: &str,
5509) -> LinkResult<PathBuf> {
5510    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5511        return Err(invalid_feed("v2 download cache address is invalid"));
5512    }
5513    let path = cfg
5514        .state_dir
5515        .join("downloads")
5516        .join(brain)
5517        .join(transaction);
5518    let directory = open_or_create_dir_nofollow(&path)?;
5519    use std::os::fd::AsRawFd as _;
5520    if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
5521        return Err(std::io::Error::last_os_error().into());
5522    }
5523    directory.sync_all()?;
5524    Ok(path)
5525}
5526
5527#[cfg(windows)]
5528fn v2_download_cache_dir(
5529    cfg: &HubConfig,
5530    brain: &str,
5531    pointer: &V2PointerBody,
5532) -> LinkResult<PathBuf> {
5533    v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
5534}
5535
5536#[cfg(windows)]
5537fn v2_download_cache_dir_for(
5538    cfg: &HubConfig,
5539    brain: &str,
5540    transaction: &str,
5541) -> LinkResult<PathBuf> {
5542    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5543        return Err(invalid_feed("v2 download cache address is invalid"));
5544    }
5545    let path = cfg
5546        .state_dir
5547        .join("downloads")
5548        .join(brain)
5549        .join(transaction);
5550    crate::fsx::write_atomic(&path.join(".directory"), b"v2 download cache\n")?;
5551    crate::fsx::open_directory_nofollow(&path)?;
5552    Ok(path)
5553}
5554
5555#[cfg(unix)]
5556fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
5557    use std::os::fd::AsRawFd as _;
5558    let parent = cfg.state_dir.join("downloads").join(brain);
5559    let Ok(directory) = open_existing_dir_nofollow(&parent) else {
5560        return;
5561    };
5562    let Ok(name) = c_name(transaction.as_bytes(), transaction) else {
5563        return;
5564    };
5565    let _ = remove_tree_at(directory.as_raw_fd(), &name, &parent.display().to_string());
5566    let _ = directory.sync_all();
5567}
5568
5569#[cfg(windows)]
5570fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
5571    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5572        return;
5573    }
5574    let parent = cfg.state_dir.join("downloads").join(brain);
5575    let Ok(root) = crate::fsx::open_directory_nofollow(&parent) else {
5576        return;
5577    };
5578    let _ = crate::fsx::remove_tree_beneath(&root, Path::new(transaction));
5579}
5580
5581#[cfg(not(any(unix, windows)))]
5582fn cleanup_v2_download_cache(_cfg: &HubConfig, _brain: &str, _transaction: &str) {}
5583
5584#[cfg(not(any(unix, windows)))]
5585fn v2_download_cache_dir_for(
5586    _cfg: &HubConfig,
5587    _brain: &str,
5588    _transaction: &str,
5589) -> LinkResult<PathBuf> {
5590    Err(LinkError::UnsupportedPlatform {
5591        operation: "resumable v2 download staging",
5592    })
5593}
5594
5595#[cfg(any(unix, windows))]
5596fn cached_blob_is_exact(path: &Path, sha256: &str, bytes: u64) -> LinkResult<bool> {
5597    let file = match crate::fsx::open_regular_nofollow(path) {
5598        Ok(file) => file,
5599        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
5600        Err(error) => return Err(error.into()),
5601    };
5602    if file.metadata()?.len() != bytes {
5603        return Ok(false);
5604    }
5605    Ok(content_sha256_reader(file)? == sha256)
5606}
5607
5608#[cfg(any(unix, windows))]
5609fn cache_v2_blob_bytes(
5610    cache_dir: &Path,
5611    sha256: &str,
5612    expected_bytes: u64,
5613    bytes: &[u8],
5614) -> LinkResult<PathBuf> {
5615    if bytes.len() as u64 != expected_bytes || content_sha256(bytes) != sha256 {
5616        return Err(invalid_feed("v2 cached blob differs from its declaration"));
5617    }
5618    let path = cache_dir.join(sha256);
5619    if !cached_blob_is_exact(&path, sha256, expected_bytes)? {
5620        crate::fsx::write_atomic(&path, bytes)?;
5621    }
5622    Ok(path)
5623}
5624
5625#[cfg(not(any(unix, windows)))]
5626fn cache_v2_blob_bytes(
5627    _cache_dir: &Path,
5628    _sha256: &str,
5629    _expected_bytes: u64,
5630    _bytes: &[u8],
5631) -> LinkResult<PathBuf> {
5632    Err(LinkError::UnsupportedPlatform {
5633        operation: "resumable v2 download staging",
5634    })
5635}
5636
5637#[cfg(unix)]
5638fn download_presigned_to_cache(
5639    cfg: &HubConfig,
5640    url: &str,
5641    cache_dir: &Path,
5642    sha256: &str,
5643    expected_bytes: u64,
5644) -> LinkResult<PathBuf> {
5645    use std::os::fd::{AsRawFd as _, FromRawFd as _};
5646
5647    let target = cache_dir.join(sha256);
5648    if cached_blob_is_exact(&target, sha256, expected_bytes)? {
5649        return Ok(target);
5650    }
5651    let directory = open_existing_dir_nofollow(cache_dir)?;
5652    let mut nonce = [0_u8; 16];
5653    ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut nonce)
5654        .map_err(|_| invalid_feed("could not mint a download cache name"))?;
5655    let temp_string = format!(".download-{}", URL_SAFE_NO_PAD.encode(nonce));
5656    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5657    let fd = unsafe {
5658        libc::openat(
5659            directory.as_raw_fd(),
5660            temp.as_ptr(),
5661            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5662            0o600,
5663        )
5664    };
5665    if fd < 0 {
5666        return Err(std::io::Error::last_os_error().into());
5667    }
5668    let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
5669    let response = match presigned_agent(cfg, url)?.get(url).call() {
5670        Ok(response) => response,
5671        Err(ureq::Error::Status(_, response)) => {
5672            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5673            return Err(LinkError::Http {
5674                what: "v2 direct download",
5675                status: response.status(),
5676                message: "object store rejected the download".to_string(),
5677                code: None,
5678                details: None,
5679            });
5680        }
5681        Err(ureq::Error::Transport(error)) => {
5682            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5683            return Err(LinkError::Transport {
5684                hub: cfg.hub.clone(),
5685                message: error.to_string(),
5686            });
5687        }
5688    };
5689    let mut reader = response
5690        .into_reader()
5691        .take(expected_bytes.saturating_add(1));
5692    let mut digest = Sha256::new();
5693    let mut total = 0_u64;
5694    let mut buffer = [0_u8; 64 * 1024];
5695    // Reading the socket and writing the file are different failures wearing
5696    // the same io::Error: a stalled download is the peer's problem and a full
5697    // disk is this machine's, and an operator who is told only "timed out"
5698    // cannot tell which one to act on.
5699    let write_result = (|| -> LinkResult<()> {
5700        loop {
5701            let read = reader
5702                .read(&mut buffer)
5703                .map_err(|error| LinkError::Transport {
5704                    hub: cfg.hub.clone(),
5705                    message: error.to_string(),
5706                })?;
5707            if read == 0 {
5708                break;
5709            }
5710            total = total.saturating_add(read as u64);
5711            digest.update(&buffer[..read]);
5712            output.write_all(&buffer[..read])?;
5713        }
5714        output.sync_all().map_err(LinkError::from)
5715    })();
5716    if let Err(error) = write_result {
5717        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5718        return Err(error);
5719    }
5720    drop(output);
5721    if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
5722        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5723        return Err(invalid_feed(
5724            "v2 direct download failed integrity verification",
5725        ));
5726    }
5727    let target_name = c_name(sha256.as_bytes(), sha256)?;
5728    // Replace only a previously inspected cache leaf. Cache bytes are private
5729    // and content-addressed; final install re-verifies them again.
5730    if unsafe {
5731        libc::renameat(
5732            directory.as_raw_fd(),
5733            temp.as_ptr(),
5734            directory.as_raw_fd(),
5735            target_name.as_ptr(),
5736        )
5737    } != 0
5738    {
5739        let error = std::io::Error::last_os_error();
5740        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5741        return Err(error.into());
5742    }
5743    directory.sync_all()?;
5744    Ok(target)
5745}
5746
5747#[cfg(windows)]
5748fn download_presigned_to_cache(
5749    cfg: &HubConfig,
5750    url: &str,
5751    cache_dir: &Path,
5752    sha256: &str,
5753    expected_bytes: u64,
5754) -> LinkResult<PathBuf> {
5755    use std::fs::OpenOptions;
5756
5757    let target = cache_dir.join(sha256);
5758    if cached_blob_is_exact(&target, sha256, expected_bytes)? {
5759        return Ok(target);
5760    }
5761    // Keep the checked directory handle alive while the private cache path is
5762    // used. Windows opens omit FILE_SHARE_DELETE, so neither this directory nor
5763    // an ancestor can be swapped after validation.
5764    let _directory = crate::fsx::open_directory_nofollow(cache_dir)?;
5765    let temp = cache_dir.join(format!(".download-{}", crate::ulid::mint()));
5766    let mut output = OpenOptions::new()
5767        .write(true)
5768        .create_new(true)
5769        .open(&temp)?;
5770    let response = match presigned_agent(cfg, url)?.get(url).call() {
5771        Ok(response) => response,
5772        Err(ureq::Error::Status(_, response)) => {
5773            let _ = std::fs::remove_file(&temp);
5774            return Err(LinkError::Http {
5775                what: "v2 direct download",
5776                status: response.status(),
5777                message: "object store rejected the download".to_string(),
5778                code: None,
5779                details: None,
5780            });
5781        }
5782        Err(ureq::Error::Transport(error)) => {
5783            let _ = std::fs::remove_file(&temp);
5784            return Err(LinkError::Transport {
5785                hub: cfg.hub.clone(),
5786                message: error.to_string(),
5787            });
5788        }
5789    };
5790    let mut reader = response
5791        .into_reader()
5792        .take(expected_bytes.saturating_add(1));
5793    let mut digest = Sha256::new();
5794    let mut total = 0_u64;
5795    let mut buffer = [0_u8; 64 * 1024];
5796    // Same split as the cache-fill path: a stalled socket is not a disk error.
5797    let copied = (|| -> LinkResult<()> {
5798        loop {
5799            let read = reader
5800                .read(&mut buffer)
5801                .map_err(|error| LinkError::Transport {
5802                    hub: cfg.hub.clone(),
5803                    message: error.to_string(),
5804                })?;
5805            if read == 0 {
5806                break;
5807            }
5808            total = total.saturating_add(read as u64);
5809            digest.update(&buffer[..read]);
5810            output.write_all(&buffer[..read])?;
5811        }
5812        output.sync_all()?;
5813        Ok(())
5814    })();
5815    if let Err(error) = copied {
5816        let _ = std::fs::remove_file(&temp);
5817        return Err(error.into());
5818    }
5819    drop(output);
5820    if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
5821        let _ = std::fs::remove_file(&temp);
5822        return Err(invalid_feed(
5823            "v2 direct download failed integrity verification",
5824        ));
5825    }
5826    if target.exists() {
5827        std::fs::remove_file(&target)?;
5828    }
5829    if let Err(error) = std::fs::rename(&temp, &target) {
5830        let _ = std::fs::remove_file(&temp);
5831        return Err(error.into());
5832    }
5833    Ok(target)
5834}
5835
5836#[cfg(not(any(unix, windows)))]
5837fn download_presigned_to_cache(
5838    _cfg: &HubConfig,
5839    _url: &str,
5840    _cache_dir: &Path,
5841    _sha256: &str,
5842    _expected_bytes: u64,
5843) -> LinkResult<PathBuf> {
5844    Err(LinkError::UnsupportedPlatform {
5845        operation: "resumable v2 download staging",
5846    })
5847}
5848
5849fn download_v2_blobs(
5850    cfg: &HubConfig,
5851    brain: &str,
5852    pointer: &V2PointerBody,
5853    pending: Vec<(&String, &V2BaselineFile)>,
5854) -> LinkResult<Vec<(String, Vec<u8>)>> {
5855    if pending.is_empty() {
5856        return Ok(Vec::new());
5857    }
5858    let expected_order = pending
5859        .iter()
5860        .map(|(path, _)| (*path).clone())
5861        .collect::<Vec<_>>();
5862    let mut streamed = std::collections::BTreeMap::new();
5863    let mut direct = Vec::new();
5864    let mut window = Vec::new();
5865    let mut window_bytes = 0_u64;
5866    let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
5867                 window_bytes: &mut u64,
5868                 streamed: &mut std::collections::BTreeMap<String, Vec<u8>>|
5869     -> LinkResult<()> {
5870        if window.is_empty() {
5871            return Ok(());
5872        }
5873        for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, window)? {
5874            if streamed.insert(path, bytes).is_some() {
5875                return Err(invalid_feed("v2 bulk streams repeated a path"));
5876            }
5877        }
5878        window.clear();
5879        *window_bytes = 0;
5880        Ok(())
5881    };
5882    for &(path, file) in &pending {
5883        if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
5884            flush(&mut window, &mut window_bytes, &mut streamed)?;
5885            direct.push((path, file));
5886            continue;
5887        }
5888        if window.len() == V2_BULK_STREAM_FILES
5889            || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
5890        {
5891            flush(&mut window, &mut window_bytes, &mut streamed)?;
5892        }
5893        window.push((path, file));
5894        window_bytes += file.bytes;
5895    }
5896    flush(&mut window, &mut window_bytes, &mut streamed)?;
5897
5898    let downloads = prepare_v2_downloads(cfg, brain, pointer, &direct)?;
5899    let next = std::sync::atomic::AtomicUsize::new(0);
5900    let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
5901    let mut results = std::iter::repeat_with(|| None)
5902        .take(downloads.len())
5903        .collect::<Vec<Option<LinkResult<(String, Vec<u8>)>>>>();
5904    std::thread::scope(|scope| {
5905        let (sender, receiver) = std::sync::mpsc::channel();
5906        for _ in 0..worker_count {
5907            let sender = sender.clone();
5908            let downloads = &downloads;
5909            let next = &next;
5910            scope.spawn(move || loop {
5911                let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5912                let Some(item) = downloads.get(index) else {
5913                    break;
5914                };
5915                let result = download_v2_blob(cfg, item).map(|bytes| (item.path.clone(), bytes));
5916                if sender.send((index, result)).is_err() {
5917                    break;
5918                }
5919            });
5920        }
5921        drop(sender);
5922        for (index, result) in receiver {
5923            results[index] = Some(result);
5924        }
5925    });
5926    for result in results.into_iter().map(|result| {
5927        result.ok_or_else(|| LinkError::Transport {
5928            hub: cfg.hub.clone(),
5929            message: "a bounded v2 blob worker stopped before reporting its result".to_string(),
5930        })?
5931    }) {
5932        let (path, bytes) = result?;
5933        if streamed.insert(path, bytes).is_some() {
5934            return Err(invalid_feed("v2 download lanes repeated a path"));
5935        }
5936    }
5937    expected_order
5938        .into_iter()
5939        .map(|path| {
5940            streamed
5941                .remove(&path)
5942                .map(|bytes| (path, bytes))
5943                .ok_or_else(|| invalid_feed("v2 download lanes omitted a proven path"))
5944        })
5945        .collect()
5946}
5947
5948/// Resume an exact-head materialization into the private global cache. Each
5949/// verified frame is durable before the next network window begins, so a
5950/// retry skips it and memory never grows with total changed bytes.
5951#[cfg(any(unix, windows))]
5952fn stage_v2_blobs(
5953    cfg: &HubConfig,
5954    brain: &str,
5955    pointer: &V2PointerBody,
5956    pending: Vec<(&String, &V2BaselineFile)>,
5957) -> LinkResult<Vec<V2StagedFile>> {
5958    let cache_dir = v2_download_cache_dir(cfg, brain, pointer)?;
5959    let mut staged = std::collections::BTreeMap::<String, V2StagedFile>::new();
5960    let mut direct = Vec::new();
5961    let mut window = Vec::new();
5962    let mut window_bytes = 0_u64;
5963    let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
5964                 window_bytes: &mut u64,
5965                 staged: &mut std::collections::BTreeMap<String, V2StagedFile>|
5966     -> LinkResult<()> {
5967        if window.is_empty() {
5968            return Ok(());
5969        }
5970        let missing = window
5971            .iter()
5972            .filter_map(|(path, file)| {
5973                let target = cache_dir.join(&file.sha256);
5974                match cached_blob_is_exact(&target, &file.sha256, file.bytes) {
5975                    Ok(true) => {
5976                        staged.insert(
5977                            (*path).clone(),
5978                            V2StagedFile {
5979                                path: (*path).clone(),
5980                                source: target,
5981                                sha256: file.sha256.clone(),
5982                                bytes: file.bytes,
5983                            },
5984                        );
5985                        None
5986                    }
5987                    Ok(false) => Some(Ok((*path, *file))),
5988                    Err(error) => Some(Err(error)),
5989                }
5990            })
5991            .collect::<LinkResult<Vec<_>>>()?;
5992        if !missing.is_empty() {
5993            for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, &missing)? {
5994                let file = missing
5995                    .iter()
5996                    .find_map(|(expected_path, file)| (*expected_path == &path).then_some(*file))
5997                    .ok_or_else(|| invalid_feed("v2 stream returned an unrequested cache path"))?;
5998                let source = cache_v2_blob_bytes(&cache_dir, &file.sha256, file.bytes, &bytes)?;
5999                staged.insert(
6000                    path.clone(),
6001                    V2StagedFile {
6002                        path,
6003                        source,
6004                        sha256: file.sha256.clone(),
6005                        bytes: file.bytes,
6006                    },
6007                );
6008            }
6009        }
6010        window.clear();
6011        *window_bytes = 0;
6012        Ok(())
6013    };
6014    for &(path, file) in &pending {
6015        if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
6016            flush(&mut window, &mut window_bytes, &mut staged)?;
6017            direct.push((path, file));
6018            continue;
6019        }
6020        if window.len() == V2_BULK_STREAM_FILES
6021            || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
6022        {
6023            flush(&mut window, &mut window_bytes, &mut staged)?;
6024        }
6025        window.push((path, file));
6026        window_bytes += file.bytes;
6027    }
6028    flush(&mut window, &mut window_bytes, &mut staged)?;
6029    for item in prepare_v2_downloads(cfg, brain, pointer, &direct)? {
6030        let source =
6031            download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
6032        staged.insert(
6033            item.path.clone(),
6034            V2StagedFile {
6035                path: item.path,
6036                source,
6037                sha256: item.sha256,
6038                bytes: item.bytes,
6039            },
6040        );
6041    }
6042    pending
6043        .into_iter()
6044        .map(|(path, _)| {
6045            staged
6046                .remove(path)
6047                .ok_or_else(|| invalid_feed("v2 download cache omitted a proven path"))
6048        })
6049        .collect()
6050}
6051
6052#[cfg(not(any(unix, windows)))]
6053fn stage_v2_blobs(
6054    _cfg: &HubConfig,
6055    _brain: &str,
6056    _pointer: &V2PointerBody,
6057    _pending: Vec<(&String, &V2BaselineFile)>,
6058) -> LinkResult<Vec<V2StagedFile>> {
6059    Err(LinkError::UnsupportedPlatform {
6060        operation: "resumable v2 download staging",
6061    })
6062}
6063
6064const V2_CONFLICT_BUNDLE_MAX: usize = 32;
6065const V2_CONFLICT_BUNDLE_TTL_SECS: u64 = 7 * 24 * 60 * 60;
6066const V2_CONFLICT_REMOTE_BYTES_MAX: u64 = 64 * 1024 * 1024;
6067
6068#[derive(Debug, Clone, Deserialize, Serialize)]
6069struct V2ConflictCoordinate {
6070    sha256: Option<String>,
6071    bytes: Option<u64>,
6072    file: Option<String>,
6073}
6074
6075#[derive(Debug, Clone, Deserialize, Serialize)]
6076struct V2ConflictFile {
6077    path: String,
6078    base: V2ConflictCoordinate,
6079    local: V2ConflictCoordinate,
6080    remote: V2ConflictCoordinate,
6081}
6082
6083#[derive(Debug, Clone, Deserialize, Serialize)]
6084struct V2ConflictPlan {
6085    v: u8,
6086    class: String,
6087    bundle: String,
6088    brain: String,
6089    origin: String,
6090    created_unix: u64,
6091    expires_unix: u64,
6092    base_seq: Option<u64>,
6093    base_commit: Option<String>,
6094    remote_seq: u64,
6095    remote_commit: Option<String>,
6096    remote_content_root: Option<String>,
6097    view_kind: String,
6098    view_revision: String,
6099    files: Vec<V2ConflictFile>,
6100}
6101
6102fn v2_take_remote_selection(
6103    files: &[V2ConflictFile],
6104    current: &std::collections::BTreeMap<String, V2BaselineFile>,
6105) -> LinkResult<(
6106    std::collections::BTreeMap<String, V2BaselineFile>,
6107    Vec<String>,
6108)> {
6109    let mut selected = std::collections::BTreeMap::new();
6110    let mut deleted = Vec::new();
6111    for file in files {
6112        match (&file.remote.sha256, file.remote.bytes) {
6113            (Some(sha256), Some(bytes)) => {
6114                let proven = current.get(&file.path).ok_or_else(|| {
6115                    invalid_feed("conflict remote coordinate disappeared from the exact head")
6116                })?;
6117                if proven.sha256 != *sha256 || proven.bytes != bytes {
6118                    return Err(invalid_feed(
6119                        "conflict remote coordinate differs from the exact head",
6120                    ));
6121                }
6122                if selected.insert(file.path.clone(), proven.clone()).is_some() {
6123                    return Err(invalid_feed("conflict plan repeats a remote coordinate"));
6124                }
6125            }
6126            (None, None) => {
6127                if current.contains_key(&file.path) {
6128                    return Err(invalid_feed(
6129                        "conflict remote deletion differs from the exact head",
6130                    ));
6131                }
6132                deleted.push(file.path.clone());
6133            }
6134            _ => return Err(invalid_feed("conflict remote coordinate is incomplete")),
6135        }
6136    }
6137    Ok((selected, deleted))
6138}
6139
6140fn v2_conflict_relative(bundle: &str, suffix: &str) -> PathBuf {
6141    PathBuf::from(".dbmd")
6142        .join("conflicts")
6143        .join(bundle)
6144        .join(suffix)
6145}
6146
6147fn read_historical_conflict_blob(
6148    cfg: &HubConfig,
6149    brain: &str,
6150    baseline: &V2SyncBaseline,
6151    path: &str,
6152    file: &V2BaselineFile,
6153) -> LinkResult<Option<Vec<u8>>> {
6154    let (Some(seq), Some(commit)) = (baseline.head_seq, baseline.commit_hash.as_deref()) else {
6155        return Ok(None);
6156    };
6157    if seq == 0 {
6158        return Ok(None);
6159    }
6160    let encoded_path: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
6161    let endpoint = format!(
6162        "/api/hub/brains/{brain}/v2/history/blob?seq={seq}&commit={commit}&path={encoded_path}&sha256={}",
6163        file.sha256
6164    );
6165    let raw = request_raw(cfg, "GET", &endpoint, None, Auth::Required, file.bytes)?;
6166    if raw.status == 404 || raw.status == 403 {
6167        return Ok(None);
6168    }
6169    let bytes = ensure_raw_ok(raw, "v2 conflict base")?;
6170    if bytes.len() as u64 != file.bytes || content_sha256(&bytes) != file.sha256 {
6171        return Err(invalid_feed(
6172            "v2 conflict base failed integrity verification",
6173        ));
6174    }
6175    Ok(Some(bytes))
6176}
6177
6178/// Persist a readable content conflict without modifying either side. The
6179/// plan is written last, so an interrupted bundle build is never actionable.
6180fn create_v2_conflict_bundle(
6181    cfg: &HubConfig,
6182    store: &Store,
6183    head: &V2VerifiedHead,
6184    baseline: Option<&V2SyncBaseline>,
6185    local: &std::collections::BTreeMap<String, (String, u64)>,
6186    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6187    paths: &[String],
6188) -> LinkResult<(String, Vec<String>)> {
6189    let conflicts_root = Path::new(".dbmd/conflicts");
6190    store.create_dir_all(conflicts_root)?;
6191    let completed = store
6192        .directory_names(conflicts_root)?
6193        .into_iter()
6194        .filter(|name| name.to_str().is_some_and(crate::ulid::is_ulid))
6195        .count();
6196    if completed >= V2_CONFLICT_BUNDLE_MAX {
6197        return Err(LinkError::InvalidPack {
6198            message: format!(
6199                "private conflict cache has {completed} bundles; resolve or prune one before syncing"
6200            ),
6201        });
6202    }
6203
6204    // A conflict bundle is diagnostic state, not a second whole-brain cache.
6205    // Materialize a deterministic prefix whose remote artifacts fit one
6206    // bounded window. Resolving it exposes the next prefix on the next sync.
6207    let mut selected_paths = Vec::new();
6208    let mut selected_remote_bytes = 0_u64;
6209    for path in paths {
6210        let bytes = remote.get(path).map_or(0, |file| file.bytes);
6211        if !selected_paths.is_empty()
6212            && selected_remote_bytes.saturating_add(bytes) > V2_CONFLICT_REMOTE_BYTES_MAX
6213        {
6214            break;
6215        }
6216        selected_remote_bytes = selected_remote_bytes.saturating_add(bytes);
6217        selected_paths.push(path.clone());
6218        if selected_remote_bytes >= V2_CONFLICT_REMOTE_BYTES_MAX {
6219            break;
6220        }
6221    }
6222    if selected_paths.is_empty() {
6223        return Err(invalid_feed("content conflict set is empty"));
6224    }
6225    let bundle = crate::ulid::mint();
6226    let bundle_root = v2_conflict_relative(&bundle, "");
6227    store.create_dir_all(&bundle_root.join("files"))?;
6228    let pointer = head.pointer.as_ref();
6229    let remote_bytes = match pointer {
6230        Some(pointer) => download_v2_blobs(
6231            cfg,
6232            &head.brain_id,
6233            pointer,
6234            selected_paths
6235                .iter()
6236                .filter_map(|path| {
6237                    remote
6238                        .get(path)
6239                        .filter(|file| file.bytes <= V2_CONFLICT_REMOTE_BYTES_MAX)
6240                        .map(|file| (path, file))
6241                })
6242                .collect(),
6243        )?
6244        .into_iter()
6245        .collect::<std::collections::BTreeMap<_, _>>(),
6246        None => std::collections::BTreeMap::new(),
6247    };
6248
6249    let mut files = Vec::with_capacity(selected_paths.len());
6250    for (index, path) in selected_paths.iter().enumerate() {
6251        let base_file = baseline.and_then(|state| state.files.get(path));
6252        let base_bytes = match (baseline, base_file) {
6253            (Some(state), Some(file)) => {
6254                read_historical_conflict_blob(cfg, &head.brain_id, state, path, file)?
6255            }
6256            _ => None,
6257        };
6258        let local_file = local.get(path);
6259        let remote_file = remote.get(path);
6260        let remote_content = remote_bytes.get(path);
6261        let prefix = format!("files/{index:04}");
6262        let base_name = base_bytes.as_ref().map(|_| format!("{prefix}.base"));
6263        let local_name = local_file.as_ref().map(|_| format!("{prefix}.local"));
6264        let remote_name = remote_content.as_ref().map(|_| format!("{prefix}.remote"));
6265        if let (Some(name), Some(bytes)) = (&base_name, &base_bytes) {
6266            store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
6267        }
6268        if let (Some(name), Some((expected_hash, expected_bytes))) = (&local_name, local_file) {
6269            let bytes = store.read_bounded(Path::new(path), *expected_bytes)?;
6270            if bytes.len() as u64 != *expected_bytes || content_sha256(&bytes) != *expected_hash {
6271                return Err(LinkError::InvalidPack {
6272                    message: format!("local conflict path `{path}` changed while bundling"),
6273                });
6274            }
6275            store.write_atomic_new(&v2_conflict_relative(&bundle, name), &bytes)?;
6276        }
6277        if let (Some(name), Some(bytes)) = (&remote_name, remote_content) {
6278            store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
6279        }
6280        files.push(V2ConflictFile {
6281            path: path.clone(),
6282            base: V2ConflictCoordinate {
6283                sha256: base_file.map(|file| file.sha256.clone()),
6284                bytes: base_file.map(|file| file.bytes),
6285                file: base_name,
6286            },
6287            local: V2ConflictCoordinate {
6288                sha256: local_file.map(|(sha256, _)| sha256.clone()),
6289                bytes: local_file.map(|(_, bytes)| *bytes),
6290                file: local_name,
6291            },
6292            remote: V2ConflictCoordinate {
6293                sha256: remote_file.map(|file| file.sha256.clone()),
6294                bytes: remote_file.map(|file| file.bytes),
6295                file: remote_name,
6296            },
6297        });
6298    }
6299    let now = SystemTime::now()
6300        .duration_since(UNIX_EPOCH)
6301        .unwrap_or_default()
6302        .as_secs();
6303    let plan = V2ConflictPlan {
6304        v: 2,
6305        class: "content_resolution_required".to_string(),
6306        bundle: bundle.clone(),
6307        brain: head.brain_id.clone(),
6308        origin: normalized_origin(&cfg.hub)?,
6309        created_unix: now,
6310        expires_unix: now.saturating_add(V2_CONFLICT_BUNDLE_TTL_SECS),
6311        base_seq: baseline.and_then(|state| state.head_seq),
6312        base_commit: baseline.and_then(|state| state.commit_hash.clone()),
6313        remote_seq: pointer.map_or(0, |value| value.seq),
6314        remote_commit: pointer.map(|value| value.commit_hash.clone()),
6315        remote_content_root: pointer.and_then(|value| value.content_root.clone()),
6316        view_kind: head.view_kind.clone(),
6317        view_revision: head.view_revision.clone(),
6318        files,
6319    };
6320    let mut bytes = serde_json::to_vec_pretty(&plan)
6321        .map_err(|_| invalid_feed("could not serialize v2 conflict plan"))?;
6322    bytes.push(b'\n');
6323    store.write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), &bytes)?;
6324    Ok((bundle, selected_paths))
6325}
6326
6327fn v2_sync_pull_with_resolution(
6328    cfg: &HubConfig,
6329    requested_brain: &str,
6330    expected_head: V2VerifiedHead,
6331    out: Option<&Path>,
6332    take_remote: Option<&std::collections::BTreeSet<String>>,
6333) -> LinkResult<V2PulledSnapshot> {
6334    let dest = out
6335        .map(Path::to_path_buf)
6336        .unwrap_or_else(|| PathBuf::from(requested_brain));
6337    let _operation_lock = lock_v2_sync_operation(cfg, &expected_head.brain_id)?;
6338    recover_v2_pull(cfg, &expected_head.brain_id, &dest)?;
6339    let head = v2_verified_head(cfg, requested_brain)?
6340        .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
6341    if take_remote.is_some() && !same_v2_head(&expected_head, &head) {
6342        return Err(LinkError::RemoteAdvancedDuringSync);
6343    }
6344    let remote = files_for_v2_view(
6345        &head,
6346        v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6347    );
6348    let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
6349    let baseline = load_v2_baseline(cfg, &head.brain_id, &dest)?;
6350    ensure_v2_view_compatible(&head, baseline.as_ref())?;
6351    let local_store = Store::open_strict(&dest).ok();
6352    // A baseline proves this path is an established checkout. Never reinterpret
6353    // a damaged/edited store marker as an empty clone destination: doing so
6354    // would let the pull half erase the local evidence before the push half can
6355    // reject it (most importantly for the generated scoped-view DB.md).
6356    ensure_established_v2_checkout_opened(&head, baseline.as_ref(), local_store.is_some())?;
6357    let mut local_view = local_store.as_ref().map(v2_local_files).transpose()?;
6358    if head.view_kind == "scoped" && baseline.is_none() && local_view.is_some() {
6359        return Err(LinkError::ScopedViewChanged);
6360    }
6361    if let Some(view) = local_view.as_mut() {
6362        remove_scoped_projection(&head, baseline.as_ref(), view)?;
6363    }
6364    let empty_local = std::collections::BTreeMap::new();
6365    let local = local_view
6366        .as_ref()
6367        .map_or(&empty_local, |view| &view.riding);
6368    let kept_home = |path: &str| {
6369        local_view
6370            .as_ref()
6371            .is_some_and(|view| view.policy.keeps_home(path))
6372    };
6373    let empty_base = std::collections::BTreeMap::new();
6374    let base = baseline.as_ref().map_or(&empty_base, |state| &state.files);
6375    let empty_base_assets = std::collections::BTreeMap::new();
6376    let base_assets = baseline
6377        .as_ref()
6378        .map_or(&empty_base_assets, |state| &state.assets);
6379    let mut local_assets = local_store
6380        .as_ref()
6381        .map(v2_local_asset_records)
6382        .transpose()?
6383        .unwrap_or_default();
6384    let mut content_merge = merge_v2_pulled_records(
6385        base,
6386        &remote,
6387        local,
6388        |file, _| (file.sha256.clone(), file.bytes),
6389        |file, _| (file.sha256.clone(), file.bytes),
6390        kept_home,
6391    );
6392    if let Some(selected) = take_remote {
6393        for path in selected {
6394            if let Some(position) = content_merge
6395                .conflicts
6396                .iter()
6397                .position(|conflict| conflict == path)
6398            {
6399                content_merge.conflicts.remove(position);
6400                content_merge.accept_remote.insert(path.clone());
6401                match remote.get(path) {
6402                    Some(file) => {
6403                        content_merge
6404                            .records
6405                            .insert(path.clone(), (file.sha256.clone(), file.bytes));
6406                    }
6407                    None => {
6408                        content_merge.records.remove(path);
6409                    }
6410                }
6411            } else if !content_merge.accept_remote.contains(path) {
6412                return Err(LinkError::InvalidPack {
6413                    message: format!(
6414                        "take-remote path `{path}` is no longer at its conflict coordinate"
6415                    ),
6416                });
6417            }
6418        }
6419    }
6420    if !content_merge.conflicts.is_empty() {
6421        let mut conflicts = content_merge.conflicts.clone();
6422        conflicts.truncate(100);
6423        if let Some(store) = local_store.as_ref() {
6424            let (bundle, paths) = create_v2_conflict_bundle(
6425                cfg,
6426                store,
6427                &head,
6428                baseline.as_ref(),
6429                local,
6430                &remote,
6431                &conflicts,
6432            )?;
6433            return Err(LinkError::ConflictBundle { bundle, paths });
6434        }
6435        return Err(LinkError::Conflict { paths: conflicts });
6436    }
6437    let asset_merge = merge_v2_pulled_records(
6438        base_assets,
6439        &remote_assets,
6440        &local_assets,
6441        v2_asset_record,
6442        v2_asset_record,
6443        |_| false,
6444    );
6445    if !asset_merge.conflicts.is_empty() {
6446        let mut conflicts = asset_merge.conflicts.clone();
6447        conflicts.truncate(100);
6448        return Err(LinkError::Conflict { paths: conflicts });
6449    }
6450    let pointer = head.pointer.as_ref();
6451    let cache_transaction = pointer.map_or_else(
6452        || content_sha256(format!("empty\0{}", head.view_revision).as_bytes()),
6453        |value| value.commit_hash.clone(),
6454    );
6455    let cache_dir = v2_download_cache_dir_for(cfg, &head.brain_id, &cache_transaction)?;
6456    let mut changed = match pointer {
6457        Some(pointer) => stage_v2_blobs(
6458            cfg,
6459            &head.brain_id,
6460            pointer,
6461            remote
6462                .iter()
6463                .filter(|(path, file)| {
6464                    content_merge.accept_remote.contains(*path)
6465                        && local.get(*path).map(|value| value.0.as_str())
6466                            != Some(file.sha256.as_str())
6467                })
6468                .collect(),
6469        )?,
6470        None => Vec::new(),
6471    };
6472    let mut deleted = content_merge
6473        .accept_remote
6474        .iter()
6475        .filter(|path| !remote.contains_key(*path) && local.contains_key(*path))
6476        .cloned()
6477        .collect::<Vec<_>>();
6478    if local_assets != asset_merge.records {
6479        if asset_merge.records.is_empty() {
6480            deleted.push("assets.jsonl".to_string());
6481        } else {
6482            let bytes = v2_asset_record_manifest_bytes(&asset_merge.records)?;
6483            let sha256 = content_sha256(&bytes);
6484            let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
6485            changed.push(V2StagedFile {
6486                path: "assets.jsonl".to_string(),
6487                source,
6488                sha256,
6489                bytes: bytes.len() as u64,
6490            });
6491        }
6492    }
6493    if let Some(pointer) = pointer {
6494        let mut pending_assets = Vec::new();
6495        for (path, asset) in &remote_assets {
6496            if asset.disposition != "hosted"
6497                || kept_home(path)
6498                || !asset_merge.accept_remote.contains(path)
6499            {
6500                continue;
6501            }
6502            let already_current = local_store.as_ref().is_some_and(|store| {
6503                matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6504                    && store
6505                        .read_bounded(Path::new(path), asset.bytes)
6506                        .ok()
6507                        .is_some_and(|bytes| {
6508                            bytes.len() as u64 == asset.bytes
6509                                && content_sha256(&bytes) == asset.blob_sha256
6510                        })
6511            });
6512            if !already_current {
6513                pending_assets.push((path, asset));
6514            }
6515        }
6516        for item in prepare_v2_asset_downloads(cfg, &head.brain_id, pointer, &pending_assets)? {
6517            let source =
6518                download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
6519            changed.push(V2StagedFile {
6520                path: item.path,
6521                source,
6522                sha256: item.sha256,
6523                bytes: item.bytes,
6524            });
6525        }
6526    }
6527    for (path, prior) in base_assets {
6528        if remote_assets.contains_key(path)
6529            || kept_home(path)
6530            || !asset_merge.accept_remote.contains(path)
6531        {
6532            continue;
6533        }
6534        let unchanged = local_store.as_ref().is_some_and(|store| {
6535            matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6536                && store
6537                    .read_bounded(Path::new(path), prior.bytes)
6538                    .ok()
6539                    .is_some_and(|bytes| content_sha256(&bytes) == prior.blob_sha256)
6540        });
6541        if unchanged {
6542            deleted.push(path.clone());
6543        }
6544    }
6545    let extra_local = content_merge
6546        .records
6547        .keys()
6548        .filter(|path| !remote.contains_key(*path))
6549        .cloned()
6550        .collect::<Vec<_>>();
6551    if head.view_kind == "scoped" {
6552        for (path, bytes) in [
6553            ("DB.md".to_string(), scoped_projection_bytes(&head.brain_id)),
6554            (
6555                ".dbmd/view.json".to_string(),
6556                scoped_view_metadata(&head, remote.len())?,
6557            ),
6558        ] {
6559            let sha256 = content_sha256(&bytes);
6560            let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
6561            changed.push(V2StagedFile {
6562                path,
6563                source,
6564                sha256,
6565                bytes: bytes.len() as u64,
6566            });
6567        }
6568    }
6569    let install_changed = !changed.is_empty() || !deleted.is_empty();
6570    install_pulled_delta_sources(&dest, &changed, &deleted, true, baseline.as_ref(), &head)?;
6571    let finalized = (|| -> LinkResult<(bool, V2LocalView, std::collections::BTreeMap<String, crate::AssetRecord>)> {
6572        let installed_store =
6573            Store::open_strict(&dest).map_err(|error| LinkError::InvalidPack {
6574                message: format!("installed v2 checkout is not a valid db.md store: {error}"),
6575            })?;
6576        let installed_local = if install_changed {
6577            let mut scanned = v2_local_files(&installed_store)?;
6578            remove_scoped_projection(&head, baseline.as_ref(), &mut scanned)?;
6579            scanned
6580        } else {
6581            local_view
6582                .take()
6583                .ok_or_else(|| invalid_feed("an unchanged pull has no installed local view"))?
6584        };
6585        if installed_local.riding != content_merge.records {
6586            return Err(LinkError::InvalidPack {
6587                message: "local content changed while installing the v2 pull".to_string(),
6588            });
6589        }
6590        let installed_assets = if install_changed {
6591            v2_local_asset_records(&installed_store)?
6592        } else {
6593            std::mem::take(&mut local_assets)
6594        };
6595        if installed_assets != asset_merge.records {
6596            return Err(LinkError::InvalidPack {
6597                message: "local assets changed while installing the v2 pull".to_string(),
6598            });
6599        }
6600        let local_dirty = !v2_riding_matches_remote(&installed_local.riding, &remote, |path| {
6601            installed_local.policy.keeps_home(path)
6602        })
6603            || !v2_asset_records_match_remote(&installed_assets, &remote_assets);
6604        let final_head = v2_verified_head(cfg, requested_brain)?
6605            .ok_or_else(|| invalid_feed("v2 head disappeared during pull"))?;
6606        if !same_v2_head(&head, &final_head) {
6607            return Err(LinkError::RemoteAdvancedDuringSync);
6608        }
6609        accept_v2_head(cfg, &final_head)?;
6610        save_v2_baseline(
6611            cfg,
6612            &head.brain_id,
6613            &dest,
6614            &v2_baseline_from_head(
6615                cfg,
6616                &head,
6617                remote.clone(),
6618                remote_assets.clone(),
6619                Some(&installed_local),
6620                baseline
6621                    .as_ref()
6622                    .and_then(|current| current.checkout_id.as_deref()),
6623            )?,
6624        )?;
6625        complete_v2_pull(&dest)?;
6626        Ok((local_dirty, installed_local, installed_assets))
6627    })();
6628    let (local_dirty, installed_local, installed_assets) = match finalized {
6629        Ok(value) => value,
6630        Err(error) => {
6631            if let Err(recovery) = recover_v2_pull(cfg, &head.brain_id, &dest) {
6632                return Err(LinkError::InvalidPack {
6633                    message: format!("{error}; durable pull recovery also failed: {recovery}"),
6634                });
6635            }
6636            return Err(error);
6637        }
6638    };
6639    cleanup_v2_download_cache(cfg, &head.brain_id, &cache_transaction);
6640    let report = PullReport {
6641        brain: head.brain_id.clone(),
6642        slug: requested_brain.to_string(),
6643        head_seq: pointer.map_or(0, |value| value.seq),
6644        files: remote.len() + remote_assets.len(),
6645        dest: dest.to_string_lossy().into_owned(),
6646        extra_local,
6647        sync_status: if local_dirty {
6648            "local_dirty_after_install".to_string()
6649        } else {
6650            "synced".to_string()
6651        },
6652    };
6653    Ok(V2PulledSnapshot {
6654        report,
6655        head,
6656        files: remote,
6657        assets: remote_assets,
6658        local: installed_local,
6659        local_assets: installed_assets,
6660    })
6661}
6662
6663fn v2_sync_pull(
6664    cfg: &HubConfig,
6665    requested_brain: &str,
6666    head: V2VerifiedHead,
6667    out: Option<&Path>,
6668) -> LinkResult<PullReport> {
6669    Ok(v2_sync_pull_with_resolution(cfg, requested_brain, head, out, None)?.report)
6670}
6671
6672fn v2_expected(remote: Option<&V2BaselineFile>) -> Value {
6673    match remote {
6674        Some(file) => json!({ "kind": "blob", "hash": file.sha256 }),
6675        None => json!({ "kind": "absent" }),
6676    }
6677}
6678
6679fn v2_asset_expected(remote: Option<&V2BaselineAsset>) -> Value {
6680    match remote {
6681        Some(asset) => json!({ "kind": "asset", "hash": asset.leaf_hash }),
6682        None => json!({ "kind": "absent" }),
6683    }
6684}
6685
6686fn v2_content_withdrawal_operation(
6687    store: &Store,
6688    local_view: &V2LocalView,
6689    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6690    path: &str,
6691    reason: &str,
6692) -> LinkResult<Value> {
6693    if (!(path.starts_with("records/") || path.starts_with("sources/")) || !path.ends_with(".md"))
6694        || path == "DB.md"
6695    {
6696        return Err(LinkError::InvalidPack {
6697            message: format!("content withdrawal path `{path}` is not a record or source"),
6698        });
6699    }
6700    if !local_view.policy.keeps_home(path)
6701        || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6702    {
6703        return Err(LinkError::InvalidPack {
6704            message: format!(
6705                "withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
6706            ),
6707        });
6708    }
6709    let current = remote.get(path).ok_or_else(|| LinkError::InvalidPack {
6710        message: format!("withdrawal path `{path}` has no readable hosted coordinate"),
6711    })?;
6712    verify_v2_upload_source(store, path, &current.sha256, current.bytes)?;
6713    Ok(json!({
6714        "op": "withdraw_from_hosting",
6715        "path": path,
6716        "expected": { "kind": "blob", "hash": current.sha256 },
6717        "reason": reason,
6718    }))
6719}
6720
6721fn v2_asset_withdrawal_operation(
6722    store: &Store,
6723    local_view: &V2LocalView,
6724    path: &str,
6725    local: &crate::AssetRecord,
6726    current: &V2BaselineAsset,
6727    reason: &str,
6728) -> LinkResult<Value> {
6729    if !local_view.policy.keeps_home(path)
6730        || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6731    {
6732        return Err(LinkError::InvalidPack {
6733            message: format!(
6734                "asset withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
6735            ),
6736        });
6737    }
6738    verify_v2_upload_source(store, path, &local.sha256, local.bytes)?;
6739    if current.disposition != "hosted" || v2_asset_record(current, path) != *local {
6740        return Err(LinkError::InvalidPack {
6741            message: format!(
6742                "asset withdrawal path `{path}` must exactly match its currently hosted signed leaf"
6743            ),
6744        });
6745    }
6746    Ok(json!({
6747        "op": "asset_withdraw",
6748        "path": path,
6749        "expected": v2_asset_expected(Some(current)),
6750        "reason": reason,
6751    }))
6752}
6753
6754// A source is immutable, but its exact bytes may move from an intake namespace
6755// to an archive namespace under the distinct `promote_source` capability. A
6756// plain filesystem move appears to sync as one absent old path plus one new
6757// path. Preserve that intent as a protocol rename only when the hash has one
6758// unambiguous source delete and one unambiguous absent-destination source put.
6759// Ambiguous duplicate bytes stay as put/delete and fail closed at the hub.
6760fn infer_exact_source_promotions(operations: Vec<Value>) -> Vec<Value> {
6761    let mut deletes = std::collections::BTreeMap::<String, Vec<(usize, String)>>::new();
6762    let mut puts = std::collections::BTreeMap::<String, Vec<(usize, String, u64)>>::new();
6763    for (index, operation) in operations.iter().enumerate() {
6764        match operation.get("op").and_then(Value::as_str) {
6765            Some("delete") => {
6766                let Some(path) = operation.get("path").and_then(Value::as_str) else {
6767                    continue;
6768                };
6769                let Some(hash) = operation
6770                    .get("expected")
6771                    .and_then(|value| value.get("hash"))
6772                    .and_then(Value::as_str)
6773                else {
6774                    continue;
6775                };
6776                if path.starts_with("sources/") {
6777                    deletes
6778                        .entry(hash.to_string())
6779                        .or_default()
6780                        .push((index, path.to_string()));
6781                }
6782            }
6783            Some("put") => {
6784                let Some(path) = operation.get("path").and_then(Value::as_str) else {
6785                    continue;
6786                };
6787                let Some(hash) = operation.get("blob").and_then(Value::as_str) else {
6788                    continue;
6789                };
6790                let Some(bytes) = operation.get("bytes").and_then(Value::as_u64) else {
6791                    continue;
6792                };
6793                let destination_absent = operation
6794                    .get("expected")
6795                    .and_then(|value| value.get("kind"))
6796                    .and_then(Value::as_str)
6797                    == Some("absent");
6798                if path.starts_with("sources/") && destination_absent {
6799                    puts.entry(hash.to_string()).or_default().push((
6800                        index,
6801                        path.to_string(),
6802                        bytes,
6803                    ));
6804                }
6805            }
6806            _ => {}
6807        }
6808    }
6809    let mut rename_at = std::collections::BTreeMap::<usize, Value>::new();
6810    let mut consumed_puts = std::collections::BTreeSet::<usize>::new();
6811    for (hash, source) in deletes {
6812        let Some(destination) = puts.get(&hash) else {
6813            continue;
6814        };
6815        if source.len() != 1 || destination.len() != 1 {
6816            continue;
6817        }
6818        let (delete_index, from) = &source[0];
6819        let (put_index, to, bytes) = &destination[0];
6820        if from == to {
6821            continue;
6822        }
6823        rename_at.insert(
6824            *delete_index,
6825            json!({
6826                "op": "rename",
6827                "from": from,
6828                "to": to,
6829                "expected_from": { "kind": "blob", "hash": hash },
6830                "expected_to": { "kind": "absent" },
6831                "blob": hash,
6832                "bytes": bytes,
6833            }),
6834        );
6835        consumed_puts.insert(*put_index);
6836    }
6837    operations
6838        .into_iter()
6839        .enumerate()
6840        .filter_map(|(index, operation)| {
6841            if let Some(rename) = rename_at.remove(&index) {
6842                Some(rename)
6843            } else if consumed_puts.contains(&index) {
6844                None
6845            } else {
6846                Some(operation)
6847            }
6848        })
6849        .collect()
6850}
6851
6852fn v2_asset_value(record: &crate::AssetRecord, disposition: &str) -> Value {
6853    json!({
6854        "blob_sha256": record.sha256,
6855        "bytes": record.bytes,
6856        "media_type": record.media_type,
6857        "wrappers": record.wrappers,
6858        "required": record.required,
6859        "disposition": disposition,
6860    })
6861}
6862
6863/// Apply the exact operation subset generated by this client to an already
6864/// verified manifest. This avoids downloading the same 50k-path listing again
6865/// after an unrebased commit; a rebased response still re-reads the manifest.
6866fn apply_generated_v2_operations(
6867    operations: &[Value],
6868    local_assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
6869    candidate: &mut std::collections::BTreeMap<String, V2BaselineFile>,
6870    candidate_assets: &mut std::collections::BTreeMap<String, V2BaselineAsset>,
6871) -> LinkResult<bool> {
6872    let mut asset_changed = false;
6873    for operation in operations {
6874        match operation.get("op").and_then(Value::as_str) {
6875            Some("put") => {
6876                let path = operation
6877                    .get("path")
6878                    .and_then(Value::as_str)
6879                    .ok_or_else(|| invalid_feed("v2 put has no path"))?;
6880                let sha256 = operation
6881                    .get("blob")
6882                    .and_then(Value::as_str)
6883                    .ok_or_else(|| invalid_feed("v2 put has no blob"))?;
6884                let bytes = operation
6885                    .get("bytes")
6886                    .and_then(Value::as_u64)
6887                    .ok_or_else(|| invalid_feed("v2 put has no byte count"))?;
6888                candidate.insert(
6889                    path.to_string(),
6890                    V2BaselineFile {
6891                        sha256: sha256.to_string(),
6892                        bytes,
6893                        proof: None,
6894                    },
6895                );
6896            }
6897            Some("rename") => {
6898                let from = operation
6899                    .get("from")
6900                    .and_then(Value::as_str)
6901                    .ok_or_else(|| invalid_feed("v2 rename has no source path"))?;
6902                let to = operation
6903                    .get("to")
6904                    .and_then(Value::as_str)
6905                    .ok_or_else(|| invalid_feed("v2 rename has no destination path"))?;
6906                let sha256 = operation
6907                    .get("blob")
6908                    .and_then(Value::as_str)
6909                    .ok_or_else(|| invalid_feed("v2 rename has no blob"))?;
6910                let bytes = operation
6911                    .get("bytes")
6912                    .and_then(Value::as_u64)
6913                    .ok_or_else(|| invalid_feed("v2 rename has no byte count"))?;
6914                let expected_from = operation
6915                    .get("expected_from")
6916                    .and_then(|expected| expected.get("hash"))
6917                    .and_then(Value::as_str);
6918                let expected_to_absent = operation
6919                    .get("expected_to")
6920                    .and_then(|expected| expected.get("kind"))
6921                    .and_then(Value::as_str)
6922                    == Some("absent");
6923                if from == to
6924                    || !from.starts_with("sources/")
6925                    || !to.starts_with("sources/")
6926                    || expected_from != Some(sha256)
6927                    || !expected_to_absent
6928                    || candidate.contains_key(to)
6929                {
6930                    return Err(invalid_feed("generated v2 source rename is malformed"));
6931                }
6932                let source = candidate
6933                    .remove(from)
6934                    .ok_or_else(|| invalid_feed("v2 rename source is absent"))?;
6935                if source.sha256 != sha256 || source.bytes != bytes {
6936                    return Err(invalid_feed(
6937                        "v2 rename source differs from its exact-byte claim",
6938                    ));
6939                }
6940                candidate.insert(
6941                    to.to_string(),
6942                    V2BaselineFile {
6943                        sha256: sha256.to_string(),
6944                        bytes,
6945                        proof: None,
6946                    },
6947                );
6948            }
6949            Some("delete" | "withdraw_from_hosting") => {
6950                let path = operation
6951                    .get("path")
6952                    .and_then(Value::as_str)
6953                    .ok_or_else(|| invalid_feed("v2 delete has no path"))?;
6954                candidate.remove(path);
6955            }
6956            Some("asset_delete") => {
6957                let path = operation
6958                    .get("path")
6959                    .and_then(Value::as_str)
6960                    .ok_or_else(|| invalid_feed("v2 asset delete has no path"))?;
6961                candidate_assets.remove(path);
6962                asset_changed = true;
6963            }
6964            Some("asset_put" | "asset_resume" | "asset_withdraw") => {
6965                let path = operation
6966                    .get("path")
6967                    .and_then(Value::as_str)
6968                    .ok_or_else(|| invalid_feed("v2 asset write has no path"))?;
6969                let record = local_assets
6970                    .get(path)
6971                    .ok_or_else(|| invalid_feed("v2 asset write has no local record"))?;
6972                let disposition =
6973                    if operation.get("op").and_then(Value::as_str) == Some("asset_withdraw") {
6974                        "withheld"
6975                    } else {
6976                        operation
6977                            .get("asset")
6978                            .and_then(|asset| asset.get("disposition"))
6979                            .and_then(Value::as_str)
6980                            .ok_or_else(|| invalid_feed("v2 asset write has no disposition"))?
6981                    };
6982                candidate_assets.insert(
6983                    path.to_string(),
6984                    V2BaselineAsset {
6985                        blob_sha256: record.sha256.clone(),
6986                        bytes: record.bytes,
6987                        media_type: record.media_type.clone(),
6988                        wrappers: record.wrappers.clone(),
6989                        required: record.required,
6990                        disposition: disposition.to_string(),
6991                        // The server assigns the signed leaf address. Callers
6992                        // that need an exact asset manifest re-read it below.
6993                        leaf_hash: String::new(),
6994                    },
6995                );
6996                asset_changed = true;
6997            }
6998            _ => return Err(invalid_feed("dbmd generated an unsupported v2 operation")),
6999        }
7000    }
7001    Ok(asset_changed)
7002}
7003
7004fn v2_riding_matches_remote(
7005    local: &std::collections::BTreeMap<String, (String, u64)>,
7006    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7007    keeps_home: impl Fn(&str) -> bool,
7008) -> bool {
7009    remote.iter().all(|(path, file)| {
7010        keeps_home(path)
7011            || local.get(path).map(|value| value.0.as_str()) == Some(file.sha256.as_str())
7012    }) && local.iter().all(|(path, (hash, _))| {
7013        remote.get(path).map(|file| file.sha256.as_str()) == Some(hash.as_str())
7014    })
7015}
7016
7017#[derive(Debug, Clone)]
7018struct V2ResolutionOverride {
7019    expected_remote: Option<String>,
7020    selected_local: Option<String>,
7021}
7022
7023#[derive(Debug, Clone)]
7024struct V2UploadSource {
7025    path: String,
7026    bytes: u64,
7027}
7028
7029struct V2SyncPushOptions<'a> {
7030    resume_local_policy: bool,
7031    bulk_confirmation: Option<&'a V2BulkConfirmation>,
7032    resolution: Option<&'a std::collections::BTreeMap<String, V2ResolutionOverride>>,
7033    pulled: Option<V2PulledSnapshot>,
7034    withdrawal_paths: &'a [String],
7035    withdrawal_reason: Option<&'a str>,
7036}
7037
7038fn verify_v2_upload_source(
7039    store: &Store,
7040    path: &str,
7041    sha256: &str,
7042    expected_bytes: u64,
7043) -> LinkResult<()> {
7044    let file = store.open_regular(Path::new(path))?;
7045    if file.metadata()?.len() != expected_bytes || content_sha256_reader(file)? != sha256 {
7046        return Err(LinkError::InvalidPack {
7047            message: format!("local path `{path}` changed during sync planning"),
7048        });
7049    }
7050    Ok(())
7051}
7052
7053/// One reserved object waiting to be staged, held until its whole batch can be
7054/// uploaded together.
7055struct V2PendingUpload<'a> {
7056    url: String,
7057    headers: Value,
7058    sha256: String,
7059    source: &'a V2UploadSource,
7060}
7061
7062/// How many objects a migration stages at once. Bounded so a first push does
7063/// not behave like a burst against the object store, while still being far
7064/// faster than one-at-a-time.
7065/// Workers staging one batch. With a shared pooled connection the cost of an
7066/// object is the PUT itself rather than a handshake, so a wider pool converts
7067/// directly into throughput on a migration.
7068const V2_UPLOAD_CONCURRENCY: usize = 16;
7069
7070/// Stage a reserved batch, uploading up to `V2_UPLOAD_CONCURRENCY` objects at a
7071/// time and verifying each against the store after it lands. The first failure
7072/// is returned and the remaining work is abandoned.
7073fn upload_v2_batch_concurrently(
7074    cfg: &HubConfig,
7075    store: &Store,
7076    pending: &[V2PendingUpload<'_>],
7077) -> LinkResult<()> {
7078    if pending.is_empty() {
7079        return Ok(());
7080    }
7081    let urls = pending
7082        .iter()
7083        .map(|task| task.url.as_str())
7084        .collect::<Vec<_>>();
7085    let shared = shared_staging_agent(cfg, &urls);
7086    if pending.len() == 1 {
7087        let task = &pending[0];
7088        put_presigned_source(
7089            cfg,
7090            &task.url,
7091            &task.headers,
7092            store,
7093            task.source,
7094            shared.as_ref(),
7095        )?;
7096        return verify_v2_upload_source(store, &task.source.path, &task.sha256, task.source.bytes);
7097    }
7098    let next = std::sync::atomic::AtomicUsize::new(0);
7099    let failure: std::sync::Mutex<Option<LinkError>> = std::sync::Mutex::new(None);
7100    let workers = V2_UPLOAD_CONCURRENCY.min(pending.len());
7101    std::thread::scope(|scope| {
7102        for _ in 0..workers {
7103            scope.spawn(|| loop {
7104                if failure.lock().map(|guard| guard.is_some()).unwrap_or(true) {
7105                    return;
7106                }
7107                let index = next.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
7108                let Some(task) = pending.get(index) else {
7109                    return;
7110                };
7111                let outcome = put_presigned_source(
7112                    cfg,
7113                    &task.url,
7114                    &task.headers,
7115                    store,
7116                    task.source,
7117                    shared.as_ref(),
7118                )
7119                .and_then(|()| {
7120                    verify_v2_upload_source(
7121                        store,
7122                        &task.source.path,
7123                        &task.sha256,
7124                        task.source.bytes,
7125                    )
7126                });
7127                if let Err(error) = outcome {
7128                    if let Ok(mut guard) = failure.lock() {
7129                        guard.get_or_insert(error);
7130                    }
7131                    return;
7132                }
7133            });
7134        }
7135    });
7136    match failure.into_inner() {
7137        Ok(Some(error)) => Err(error),
7138        Ok(None) => Ok(()),
7139        Err(_) => Err(invalid_feed("v2 upload worker state was poisoned")),
7140    }
7141}
7142
7143fn put_presigned_source(
7144    cfg: &HubConfig,
7145    raw: &str,
7146    headers: &Value,
7147    store: &Store,
7148    source: &V2UploadSource,
7149    shared: Option<&ureq::Agent>,
7150) -> LinkResult<()> {
7151    // The URL's own safety checks run either way; only the connection pool is
7152    // shared, so a batch never trades a check for a warm socket.
7153    let owned = match shared {
7154        Some(_) => {
7155            checked_presigned_url(cfg, raw)?;
7156            None
7157        }
7158        None => Some(presigned_agent(cfg, raw)?),
7159    };
7160    let http = shared.unwrap_or_else(|| owned.as_ref().expect("an agent for this upload"));
7161    let mut attempt = 0;
7162    let result = loop {
7163        let file = store.open_regular(Path::new(&source.path))?;
7164        if file.metadata()?.len() != source.bytes {
7165            return Err(LinkError::InvalidPack {
7166                message: format!("local path `{}` changed before upload", source.path),
7167            });
7168        }
7169        // The reservation already states content-length, lowercased. The HTTP
7170        // client de-duplicates headers by exact name, not case, so setting
7171        // `Content-Length` here as well leaves both on the wire and an edge
7172        // rejects the request as malformed. Supply it only when absent.
7173        let mut req = http.put(raw);
7174        let mut has_content_length = false;
7175        if let Some(map) = headers.as_object() {
7176            for (name, value) in map {
7177                if let Some(value) = value.as_str() {
7178                    has_content_length |= name.eq_ignore_ascii_case("content-length");
7179                    req = req.set(name, value);
7180                }
7181            }
7182        }
7183        if !has_content_length {
7184            req = req.set("Content-Length", &source.bytes.to_string());
7185        }
7186        match req.send(file) {
7187            // Every transport failure is retryable here, not only the
7188            // pre-request kinds: staging an object is idempotent (fixed content
7189            // address, exact length and checksum, write-once precondition), the
7190            // file is reopened on each attempt, and a migration holds
7191            // connections long enough that a mid-request drop is ordinary.
7192            Err(ureq::Error::Transport(_)) if attempt + 1 < UPLOAD_ATTEMPTS => {
7193                std::thread::sleep(std::time::Duration::from_millis(upload_retry_backoff_ms(
7194                    attempt,
7195                )));
7196                attempt += 1;
7197            }
7198            // A migration stages tens of thousands of immutable objects, and an
7199            // edge or object store may refuse a single one under sustained
7200            // load. The upload is content-addressed and precondition-guarded,
7201            // so replaying it is safe; failing the whole push on one refusal is
7202            // not. 412 already means "someone else stored these exact bytes".
7203            Err(ureq::Error::Status(status, _))
7204                if status != 412
7205                    && is_retryable_upload_status(status)
7206                    && attempt + 1 < UPLOAD_ATTEMPTS =>
7207            {
7208                std::thread::sleep(std::time::Duration::from_millis(upload_retry_backoff_ms(
7209                    attempt,
7210                )));
7211                attempt += 1;
7212            }
7213            result => break result,
7214        }
7215    };
7216    match result {
7217        Ok(response) if (200..300).contains(&response.status()) => {
7218            drain_presigned_response(response);
7219            Ok(())
7220        }
7221        Ok(response) => {
7222            // The object store states its own reason (checksum mismatch,
7223            // precondition, expiry). Carrying a bounded excerpt turns an
7224            // unactionable refusal into one an operator can diagnose; the
7225            // coordinate is the store path, never file content.
7226            let status = response.status();
7227            let detail = response
7228                .into_string()
7229                .ok()
7230                .map(|body| body.chars().take(400).collect::<String>())
7231                .filter(|body| !body.trim().is_empty());
7232            Err(LinkError::Http {
7233                what: "v2 changed-byte upload",
7234                status,
7235                message: match detail {
7236                    Some(body) => format!(
7237                        "object store rejected the upload of `{}`: {}",
7238                        source.path,
7239                        body.replace('\n', " ")
7240                    ),
7241                    None => format!("object store rejected the upload of `{}`", source.path),
7242                },
7243                code: None,
7244                details: None,
7245            })
7246        }
7247        Err(error) => match error {
7248            ureq::Error::Status(412, _) => Ok(()),
7249            ureq::Error::Status(_, response) => {
7250                let status = response.status();
7251                let detail = response
7252                    .into_string()
7253                    .ok()
7254                    .map(|body| body.chars().take(400).collect::<String>())
7255                    .filter(|body| !body.trim().is_empty());
7256                Err(LinkError::Http {
7257                    what: "v2 changed-byte upload",
7258                    status,
7259                    message: match detail {
7260                        Some(body) => format!(
7261                            "object store rejected the upload of `{}`: {}",
7262                            source.path,
7263                            body.replace('\n', " ")
7264                        ),
7265                        None => {
7266                            format!("object store rejected the upload of `{}`", source.path)
7267                        }
7268                    },
7269                    code: None,
7270                    details: None,
7271                })
7272            }
7273            ureq::Error::Transport(error) => Err(LinkError::Transport {
7274                hub: "the object store".to_string(),
7275                message: error.to_string(),
7276            }),
7277        },
7278    }
7279}
7280
7281/// The request as it is hashed and signed. A staged push carries its operations
7282/// by address rather than inline, so the signed view restores them: the request
7283/// hash covers the change itself, never the transport that carried it.
7284fn v2_signed_request_view(body: &Value, operations: &[Value]) -> Value {
7285    if body.get("operations").is_some() {
7286        return body.clone();
7287    }
7288    let mut value = body.clone();
7289    if let Some(map) = value.as_object_mut() {
7290        map.remove("staged_change");
7291        map.insert("operations".to_string(), Value::Array(operations.to_vec()));
7292    }
7293    value
7294}
7295
7296/// Open one staging window, waiting out a busy hub rather than abandoning the
7297/// push. The call reserves capacity and mints URLs; it commits nothing, so
7298/// asking again after a refusal costs only the wait.
7299fn reserve_upload_window(
7300    cfg: &HubConfig,
7301    path: &str,
7302    body: &Value,
7303    what: &'static str,
7304) -> LinkResult<Value> {
7305    let mut attempt = 0;
7306    loop {
7307        let pause = |attempt: usize| {
7308            std::thread::sleep(std::time::Duration::from_millis(
7309                RESERVATION_BACKOFF_MS[attempt.min(RESERVATION_BACKOFF_MS.len() - 1)],
7310            ));
7311        };
7312        match request(cfg, "POST", path, Some(body), Auth::Required) {
7313            // A migration opens dozens of windows over tens of minutes, so one
7314            // response that never arrives is ordinary rather than final. The
7315            // call reserves capacity and mints URLs; it commits nothing, so a
7316            // dropped or timed-out attempt is worth asking again.
7317            Err(LinkError::Transport { .. }) if attempt + 1 < RESERVATION_ATTEMPTS => {
7318                pause(attempt);
7319                attempt += 1;
7320            }
7321            Err(error) => return Err(error),
7322            Ok(response) => {
7323                if is_retryable_hub_status(response.status) && attempt + 1 < RESERVATION_ATTEMPTS {
7324                    pause(attempt);
7325                    attempt += 1;
7326                    continue;
7327                }
7328                return ensure_ok(response, what);
7329            }
7330        }
7331    }
7332}
7333
7334/// The exact bytes of a staged change. The hub refuses a manifest carrying any
7335/// other key, so this is the whole contract: operations, and the blob
7336/// references they need, and nothing else.
7337fn v2_change_manifest(operations: &[Value], blobs: Value) -> LinkResult<Vec<u8>> {
7338    let bytes = serde_json::to_vec(&json!({ "operations": operations, "blobs": blobs }))
7339        .map_err(|_| invalid_feed("could not serialize the v2 change manifest"))?;
7340    if bytes.len() > MAX_STAGED_CHANGE_BYTES {
7341        return Err(LinkError::PushTooLarge {
7342            detail: "v2 change metadata exceeds the staged-change ceiling".to_string(),
7343        });
7344    }
7345    Ok(bytes)
7346}
7347
7348/// State one change's operations and blob references through the same
7349/// content-addressed lane its bytes already ride.
7350///
7351/// A first push of a real store names one operation and one blob reference per
7352/// file, which alone outgrows the bounded commit request. Staging that metadata
7353/// and addressing it by hash keeps the push a single atomic commit: the hub
7354/// verifies the address, splices the manifest in, and validates exactly what it
7355/// would have validated inline. The alternative — many partial commits — would
7356/// make a failed migration leave a half-populated brain readable.
7357fn stage_v2_change(
7358    cfg: &HubConfig,
7359    requested_brain: &str,
7360    operations: &[Value],
7361    blobs: Value,
7362) -> LinkResult<Value> {
7363    let bytes = v2_change_manifest(operations, blobs)?;
7364    let sha256 = content_sha256(&bytes);
7365    let reserved = reserve_upload_window(
7366        cfg,
7367        &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
7368        &json!({
7369            "blobs": [{
7370                "sha256": sha256,
7371                "bytes": bytes.len(),
7372                "kind": "staged_change",
7373            }],
7374        }),
7375        "stage the v2 change",
7376    )?;
7377    let items = reserved
7378        .get("uploads")
7379        .and_then(Value::as_array)
7380        .ok_or_else(|| invalid_feed("v2 change staging response has no items"))?;
7381    let [item] = items.as_slice() else {
7382        return Err(invalid_feed(
7383            "v2 change staging response changed the requested set",
7384        ));
7385    };
7386    let reservation_id = item
7387        .get("reservation_id")
7388        .and_then(Value::as_str)
7389        .ok_or_else(|| invalid_feed("v2 change staging has no opaque id"))?;
7390    if item.get("sha256").and_then(Value::as_str) != Some(sha256.as_str())
7391        || item.get("bytes").and_then(Value::as_u64) != Some(bytes.len() as u64)
7392        || !crate::ulid::is_ulid(reservation_id)
7393    {
7394        return Err(invalid_feed("v2 change staging item is inconsistent"));
7395    }
7396    match item.get("status").and_then(Value::as_str) {
7397        Some("upload") => put_presigned(
7398            cfg,
7399            item.get("url")
7400                .and_then(Value::as_str)
7401                .ok_or_else(|| invalid_feed("v2 change staging has no URL"))?,
7402            item.get("headers").unwrap_or(&Value::Null),
7403            &bytes,
7404        )?,
7405        Some("already_present") => {}
7406        _ => return Err(invalid_feed("v2 change staging has an unknown status")),
7407    }
7408    Ok(json!({
7409        "sha256": sha256,
7410        "bytes": bytes.len(),
7411        "reservation_id": reservation_id,
7412    }))
7413}
7414
7415/// Move a commit request's operations and blob references off the wire body
7416/// when they no longer fit in it. Idempotent by construction: the mutation ID
7417/// is derived from the change, never from how the change was transported.
7418fn stage_oversized_v2_change(
7419    cfg: &HubConfig,
7420    requested_brain: &str,
7421    operations: &[Value],
7422    body: &mut Value,
7423) -> LinkResult<()> {
7424    if body.to_string().len() <= MAX_PUSH_BYTES {
7425        return Ok(());
7426    }
7427    let staged = stage_v2_change(
7428        cfg,
7429        requested_brain,
7430        operations,
7431        body.get("blobs")
7432            .cloned()
7433            .unwrap_or(Value::Array(Vec::new())),
7434    )?;
7435    let map = body
7436        .as_object_mut()
7437        .ok_or_else(|| invalid_feed("v2 commit request is not an object"))?;
7438    map.remove("operations");
7439    map.remove("blobs");
7440    map.insert("staged_change".to_string(), staged);
7441    Ok(())
7442}
7443
7444fn v2_sync_push(
7445    cfg: &HubConfig,
7446    requested_brain: &str,
7447    store: &Store,
7448    head: V2VerifiedHead,
7449    options: V2SyncPushOptions<'_>,
7450) -> LinkResult<Value> {
7451    let V2SyncPushOptions {
7452        resume_local_policy,
7453        bulk_confirmation,
7454        resolution,
7455        pulled,
7456        withdrawal_paths,
7457        withdrawal_reason,
7458    } = options;
7459    let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
7460    let head = v2_verified_head(cfg, requested_brain)?
7461        .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
7462    let (mut remote, mut remote_assets, carried_local, carried_local_assets) =
7463        match pulled.filter(|snapshot| same_v2_head(&snapshot.head, &head)) {
7464            Some(snapshot) => (
7465                snapshot.files,
7466                snapshot.assets,
7467                Some(snapshot.local),
7468                Some(snapshot.local_assets),
7469            ),
7470            None => (
7471                files_for_v2_view(
7472                    &head,
7473                    v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7474                ),
7475                v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7476                None,
7477                None,
7478            ),
7479        };
7480    let baseline = load_v2_baseline(cfg, &head.brain_id, &store.root)?;
7481    ensure_v2_view_compatible(&head, baseline.as_ref())?;
7482    if head.view_kind == "scoped" && baseline.is_none() {
7483        return Err(LinkError::ScopedViewChanged);
7484    }
7485    let local_view = local_view_for_v2_push(store, &head, baseline.as_ref(), carried_local)?;
7486    let local = &local_view.riding;
7487    let local_assets = match carried_local_assets {
7488        Some(assets) => assets,
7489        None => v2_local_asset_records(store)?,
7490    };
7491    if withdrawal_paths.len() > MAX_PUSH_FILES {
7492        return Err(LinkError::PushTooLarge {
7493            detail: "too many explicit withdrawal paths".to_string(),
7494        });
7495    }
7496    let withdrawal_reason = if withdrawal_paths.is_empty() {
7497        None
7498    } else {
7499        let reason = withdrawal_reason
7500            .map(str::trim)
7501            .filter(|reason| !reason.is_empty() && reason.len() <= 1000)
7502            .ok_or_else(|| LinkError::InvalidPack {
7503                message: "explicit withdrawal requires a non-empty --withdraw-reason of at most 1000 bytes".to_string(),
7504            })?;
7505        Some(reason)
7506    };
7507    let mut withdrawals = withdrawal_paths
7508        .iter()
7509        .map(|path| {
7510            crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
7511                path: error.to_string(),
7512            })
7513        })
7514        .collect::<LinkResult<Vec<_>>>()?;
7515    withdrawals.sort();
7516    withdrawals.dedup();
7517    if withdrawals.len() != withdrawal_paths.len() {
7518        return Err(LinkError::InvalidPack {
7519            message: "explicit withdrawal paths must be unique".to_string(),
7520        });
7521    }
7522    let withdrawal_set = withdrawals.iter().cloned().collect::<BTreeSet<_>>();
7523    let mut consumed_withdrawals = BTreeSet::new();
7524    if let Some(previous) = baseline.as_ref() {
7525        if previous.local_policy_digest.as_deref() != Some(&local_view.policy.digest)
7526            && !resume_local_policy
7527        {
7528            let mut newly_eligible = previous
7529                .local_eligibility
7530                .iter()
7531                .filter(|(path, riding)| !**riding && !local_view.policy.keeps_home(path))
7532                .map(|(path, _)| path.clone())
7533                .collect::<Vec<_>>();
7534            if !newly_eligible.is_empty() {
7535                newly_eligible.truncate(100);
7536                return Err(LinkError::LocalPolicyTransition {
7537                    paths: newly_eligible,
7538                });
7539            }
7540        }
7541    }
7542    let base = match baseline.as_ref() {
7543        Some(state) => &state.files,
7544        None if remote.is_empty() => &remote,
7545        None => {
7546            let mut conflicts = remote
7547                .iter()
7548                .filter(|(path, file)| {
7549                    local.get(*path).map(|value| value.0.as_str()) != Some(file.sha256.as_str())
7550                })
7551                .map(|(path, _)| path.clone())
7552                .collect::<Vec<_>>();
7553            if !conflicts.is_empty() {
7554                conflicts.truncate(100);
7555                let (bundle, paths) =
7556                    create_v2_conflict_bundle(cfg, store, &head, None, local, &remote, &conflicts)?;
7557                return Err(LinkError::ConflictBundle { bundle, paths });
7558            }
7559            &remote
7560        }
7561    };
7562    let all_paths = base
7563        .keys()
7564        .chain(remote.keys())
7565        .chain(local.keys())
7566        .cloned()
7567        .collect::<std::collections::BTreeSet<_>>();
7568    let mut conflicts = Vec::new();
7569    let mut operations = Vec::new();
7570    let mut upload_sources = std::collections::BTreeMap::<String, V2UploadSource>::new();
7571    for path in all_paths {
7572        let base_hash = base.get(&path).map(|file| file.sha256.as_str());
7573        let remote_file = remote.get(&path);
7574        let remote_hash = remote_file.map(|file| file.sha256.as_str());
7575        let local_file = local.get(&path);
7576        let local_hash = local_file.map(|file| file.0.as_str());
7577        if local_hash == base_hash {
7578            continue;
7579        }
7580        if resolution.is_some_and(|allowed| !allowed.contains_key(&path)) {
7581            continue;
7582        }
7583        if local_view.policy.keeps_home(&path) {
7584            // Kept-home is a local transfer exclusion, never an implicit
7585            // delete of the company's already-hosted coordinate.
7586            continue;
7587        }
7588        if remote_hash != base_hash && local_hash != remote_hash {
7589            let explicitly_resolved = resolution
7590                .and_then(|allowed| allowed.get(&path))
7591                .is_some_and(|selected| {
7592                    selected.expected_remote.as_deref() == remote_hash
7593                        && selected.selected_local.as_deref() == local_hash
7594                });
7595            if !explicitly_resolved {
7596                conflicts.push(path);
7597                continue;
7598            }
7599        }
7600        match local_file {
7601            Some((sha256, byte_count)) => {
7602                verify_v2_upload_source(store, &path, sha256, *byte_count)?;
7603                operations.push(json!({
7604                    "op": "put",
7605                    "path": path,
7606                    "expected": v2_expected(remote_file),
7607                    "blob": sha256,
7608                    "bytes": byte_count,
7609                }));
7610                upload_sources
7611                    .entry(sha256.clone())
7612                    .or_insert_with(|| V2UploadSource {
7613                        path: path.clone(),
7614                        bytes: *byte_count,
7615                    });
7616            }
7617            None => {
7618                let Some(current) = remote_file else {
7619                    continue;
7620                };
7621                operations.push(json!({
7622                    "op": "delete",
7623                    "path": path,
7624                    "expected": { "kind": "blob", "hash": current.sha256 },
7625                }));
7626            }
7627        }
7628    }
7629    operations = infer_exact_source_promotions(operations);
7630    for path in &withdrawals {
7631        if local_assets.contains_key(path) {
7632            continue;
7633        }
7634        operations.push(v2_content_withdrawal_operation(
7635            store,
7636            &local_view,
7637            &remote,
7638            path,
7639            withdrawal_reason.expect("non-empty withdrawal set has a reason"),
7640        )?);
7641        consumed_withdrawals.insert(path.clone());
7642    }
7643    if !conflicts.is_empty() {
7644        conflicts.truncate(100);
7645        let (bundle, paths) = create_v2_conflict_bundle(
7646            cfg,
7647            store,
7648            &head,
7649            baseline.as_ref(),
7650            local,
7651            &remote,
7652            &conflicts,
7653        )?;
7654        return Err(LinkError::ConflictBundle { bundle, paths });
7655    }
7656    let base_assets = match baseline.as_ref() {
7657        Some(state) => &state.assets,
7658        None if remote_assets.is_empty() => &remote_assets,
7659        None => {
7660            let mismatched = remote_assets.iter().any(|(path, remote)| {
7661                local_assets.get(path) != Some(&v2_asset_record(remote, path))
7662            }) || local_assets.len() != remote_assets.len();
7663            if mismatched {
7664                return Err(LinkError::Conflict {
7665                    paths: vec!["assets.jsonl".to_string()],
7666                });
7667            }
7668            &remote_assets
7669        }
7670    };
7671    let asset_paths = base_assets
7672        .keys()
7673        .chain(remote_assets.keys())
7674        .chain(local_assets.keys())
7675        .cloned()
7676        .collect::<std::collections::BTreeSet<_>>();
7677    let mut asset_policy_transitions = Vec::new();
7678    for path in asset_paths {
7679        let base_record = base_assets
7680            .get(&path)
7681            .map(|asset| v2_asset_record(asset, &path));
7682        let remote = remote_assets.get(&path);
7683        let remote_record = remote.map(|asset| v2_asset_record(asset, &path));
7684        let local_record = local_assets.get(&path);
7685        if withdrawal_set.contains(&path) {
7686            let record = local_record.ok_or_else(|| LinkError::InvalidPack {
7687                message: format!("asset withdrawal path `{path}` is absent from assets.jsonl"),
7688            })?;
7689            let current = remote.ok_or_else(|| LinkError::InvalidPack {
7690                message: format!(
7691                    "asset withdrawal path `{path}` has no readable hosted coordinate"
7692                ),
7693            })?;
7694            operations.push(v2_asset_withdrawal_operation(
7695                store,
7696                &local_view,
7697                &path,
7698                record,
7699                current,
7700                withdrawal_reason.expect("non-empty withdrawal set has a reason"),
7701            )?);
7702            consumed_withdrawals.insert(path.clone());
7703            continue;
7704        }
7705        let mut raw_present = false;
7706        let mut disposition = "withheld";
7707        let mut resumes_hosting = false;
7708        if let Some(record) = local_record {
7709            crate::linkmd_v2::normalize_path(&record.path)
7710                .map_err(|error| invalid_feed(error.to_string()))?;
7711            let kept_home = local_view.policy.keeps_home(&path);
7712            raw_present = matches!(store.regular_file_exists(Path::new(&path)), Ok(true));
7713            disposition = if kept_home || !raw_present {
7714                "withheld"
7715            } else {
7716                "hosted"
7717            };
7718            if !raw_present && record.required && !kept_home {
7719                return Err(LinkError::InvalidPack {
7720                    message: format!("required asset {path} is missing"),
7721                });
7722            }
7723            resumes_hosting = v2_asset_resumes_hosting(remote, &path, record, disposition);
7724        }
7725        if local_record == base_record.as_ref() && !resumes_hosting {
7726            continue;
7727        }
7728        if remote_record != base_record && local_record != remote_record.as_ref() {
7729            conflicts.push(path);
7730            continue;
7731        }
7732        let Some(record) = local_record else {
7733            if let Some(remote) = remote {
7734                operations.push(json!({
7735                    "op": "asset_delete",
7736                    "path": path,
7737                    "expected": v2_asset_expected(Some(remote)),
7738                }));
7739            }
7740            continue;
7741        };
7742        let raw = if raw_present {
7743            verify_v2_upload_source(store, &path, &record.sha256, record.bytes)?;
7744            Some(())
7745        } else {
7746            None
7747        };
7748        let op = if resumes_hosting {
7749            if !resume_local_policy {
7750                asset_policy_transitions.push(path);
7751                continue;
7752            }
7753            "asset_resume"
7754        } else {
7755            "asset_put"
7756        };
7757        operations.push(json!({
7758            "op": op,
7759            "path": path,
7760            "expected": v2_asset_expected(remote),
7761            "asset": v2_asset_value(record, disposition),
7762        }));
7763        if disposition == "hosted" {
7764            raw.expect("hosted asset was checked present");
7765            upload_sources
7766                .entry(record.sha256.clone())
7767                .or_insert_with(|| V2UploadSource {
7768                    path: path.clone(),
7769                    bytes: record.bytes,
7770                });
7771        }
7772    }
7773    if consumed_withdrawals != withdrawal_set {
7774        let missing = withdrawal_set
7775            .difference(&consumed_withdrawals)
7776            .next()
7777            .expect("different withdrawal sets have one member");
7778        return Err(LinkError::InvalidPack {
7779            message: format!(
7780                "withdrawal path `{missing}` is not a readable content or asset coordinate"
7781            ),
7782        });
7783    }
7784    if !conflicts.is_empty() {
7785        conflicts.truncate(100);
7786        return Err(LinkError::Conflict { paths: conflicts });
7787    }
7788    if !asset_policy_transitions.is_empty() {
7789        asset_policy_transitions.truncate(100);
7790        return Err(LinkError::LocalPolicyTransition {
7791            paths: asset_policy_transitions,
7792        });
7793    }
7794    let touched_sources = operations
7795        .iter()
7796        .filter_map(
7797            |operation| match operation.get("op").and_then(Value::as_str) {
7798                Some("put" | "restore") => operation.get("path").and_then(Value::as_str),
7799                Some("rename") => operation.get("to").and_then(Value::as_str),
7800                _ => None,
7801            },
7802        )
7803        .collect::<std::collections::BTreeSet<_>>();
7804    let withheld_links = local_view
7805        .withheld_links
7806        .iter()
7807        .filter(|link| touched_sources.contains(link.source.as_str()))
7808        .collect::<Vec<_>>();
7809    let checkout_pseudonym = v2_checkout_id(
7810        baseline
7811            .as_ref()
7812            .and_then(|current| current.checkout_id.as_deref()),
7813    )?;
7814    let checkout_id = if withheld_links.is_empty() {
7815        None
7816    } else {
7817        Some(checkout_pseudonym.clone())
7818    };
7819    if operations.is_empty() {
7820        let final_head = v2_verified_head(cfg, requested_brain)?
7821            .ok_or_else(|| invalid_feed("v2 head disappeared during sync"))?;
7822        if !same_v2_head(&head, &final_head) {
7823            return Err(LinkError::RemoteAdvancedDuringSync);
7824        }
7825        let mut final_local = v2_local_files(store)?;
7826        remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
7827        let final_assets = v2_local_asset_records(store)?;
7828        let local_changed = final_local.riding != local_view.riding || final_assets != local_assets;
7829        let remote_ahead = !v2_riding_matches_remote(&final_local.riding, &remote, |path| {
7830            final_local.policy.keeps_home(path)
7831        }) || !v2_asset_records_match_remote(&final_assets, &remote_assets);
7832        let next = v2_baseline_from_head(
7833            cfg,
7834            &head,
7835            remote,
7836            remote_assets,
7837            Some(&final_local),
7838            Some(&checkout_pseudonym),
7839        )?;
7840        let split_count = next.remote_copy_remains.len();
7841        accept_v2_head(cfg, &final_head)?;
7842        if !local_changed && !remote_ahead {
7843            refresh_scoped_view_marker(store, &head, next.files.len())?;
7844            save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
7845        }
7846        return Ok(json!({
7847            "v": 2,
7848            "outcome": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "no_change" },
7849            "sync_status": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "synced" },
7850            "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
7851            "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
7852            "local_policy": {
7853                "remote_copy_remains": split_count,
7854            },
7855        }));
7856    }
7857    let includes_contract = operations
7858        .iter()
7859        .any(|operation| operation.get("path").and_then(Value::as_str) == Some("DB.md"));
7860    let rebase = if head.pointer.is_none() || includes_contract {
7861        "strict"
7862    } else {
7863        "disjoint"
7864    };
7865    let base_value = head.pointer.as_ref().map(|pointer| {
7866        json!({
7867            "seq": pointer.seq,
7868            "commit_hash": pointer.commit_hash,
7869            "content_root": pointer.content_root,
7870            "asset_root": pointer.asset_root,
7871        })
7872    });
7873    // The mutation coordinate is stable for the exact observed base and local
7874    // operation set. A lost response can therefore be retried without a
7875    // duplicate commit/proposal even across process restarts.
7876    let entropy = format!(
7877        "{}\0{}\0{}\0{}\0{}\0{}",
7878        normalized_origin(&cfg.hub)?,
7879        head.brain_id,
7880        serde_json::to_string(&base_value).unwrap_or_default(),
7881        serde_json::to_string(&operations).unwrap_or_default(),
7882        serde_json::to_string(&withheld_links).unwrap_or_default(),
7883        checkout_id.as_deref().unwrap_or("")
7884    );
7885    let mutation_id = format!("dbmd-{}", content_sha256(entropy.as_bytes()));
7886    let changed_bytes = upload_sources.values().try_fold(0_u64, |total, source| {
7887        total
7888            .checked_add(source.bytes)
7889            .ok_or_else(|| LinkError::PushTooLarge {
7890                detail: "v2 changed-byte total overflow".to_string(),
7891            })
7892    })?;
7893    let inline = changed_bytes <= 3 * 1024 * 1024;
7894    let inline_blobs = if inline {
7895        upload_sources
7896            .iter()
7897            .map(|(sha256, source)| {
7898                let bytes = store.read_bounded(Path::new(&source.path), source.bytes)?;
7899                if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != *sha256 {
7900                    return Err(LinkError::InvalidPack {
7901                        message: format!("local path `{}` changed before upload", source.path),
7902                    });
7903                }
7904                Ok(json!({
7905                    "sha256": sha256,
7906                    "bytes": source.bytes,
7907                    "content_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
7908                }))
7909            })
7910            .collect::<LinkResult<Vec<_>>>()?
7911    } else {
7912        Vec::new()
7913    };
7914    let mut body = json!({
7915        "mutation_id": mutation_id,
7916        "base": base_value,
7917        "rebase": rebase,
7918        "reason": "dbmd sync",
7919        "operations": operations,
7920        "blobs": inline_blobs,
7921    });
7922    if !withheld_links.is_empty() {
7923        body["withheld_links"] = serde_json::to_value(&withheld_links)
7924            .map_err(|_| invalid_feed("could not serialize withheld-link observations"))?;
7925        body["checkout_id"] =
7926            Value::String(checkout_id.expect("non-empty withheld links have a checkout pseudonym"));
7927    }
7928    if let Some(confirmation) = bulk_confirmation {
7929        if !crate::ulid::is_ulid(&confirmation.id) || !is_sha256(&confirmation.digest) {
7930            return Err(LinkError::InvalidPack {
7931                message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
7932                    .to_string(),
7933            });
7934        }
7935        // A preview is bound to the exact observed head. Confirmation can
7936        // therefore never use disjoint rebase semantics, even when the normal
7937        // small-mutation path could.
7938        body["rebase"] = Value::String("strict".to_string());
7939        body["bulk_preview_id"] = Value::String(confirmation.id.clone());
7940        body["bulk_preview_digest"] = Value::String(confirmation.digest.clone());
7941    }
7942    if !inline || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
7943        let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
7944        for operation in &operations {
7945            let Some(kind) = operation.get("op").and_then(Value::as_str) else {
7946                return Err(invalid_feed("v2 upload operation has no kind"));
7947            };
7948            let hash = match kind {
7949                "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
7950                "asset_put" | "asset_resume" => operation
7951                    .get("asset")
7952                    .and_then(|asset| asset.get("blob_sha256"))
7953                    .and_then(Value::as_str),
7954                _ => None,
7955            };
7956            let Some(hash) = hash else { continue };
7957            let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
7958            if kind == "rename" {
7959                for field in ["from", "to"] {
7960                    coordinates.insert(
7961                        operation
7962                            .get(field)
7963                            .and_then(Value::as_str)
7964                            .ok_or_else(|| invalid_feed("v2 rename upload has no coordinate"))?
7965                            .to_string(),
7966                    );
7967                }
7968            } else {
7969                let path = operation
7970                    .get("path")
7971                    .and_then(Value::as_str)
7972                    .ok_or_else(|| invalid_feed("v2 upload has no coordinate"))?;
7973                coordinates.insert(if kind.starts_with("asset_") {
7974                    format!("assets/{path}")
7975                } else {
7976                    path.to_string()
7977                });
7978            }
7979        }
7980        let declarations = upload_sources
7981            .iter()
7982            .map(|(sha256, source)| {
7983                json!({
7984                    "sha256": sha256,
7985                    "bytes": source.bytes,
7986                    "coordinates": coordinates_by_hash
7987                        .get(sha256)
7988                        .into_iter()
7989                        .flatten()
7990                        .collect::<Vec<_>>(),
7991                })
7992            })
7993            .collect::<Vec<_>>();
7994        let mut references = Vec::with_capacity(upload_sources.len());
7995        let mut seen = std::collections::BTreeSet::new();
7996        let mut reserved_count = 0usize;
7997        let mut pending_uploads: Vec<V2PendingUpload<'_>> = Vec::new();
7998        // A reservation's presigned URL is short-lived, so each batch's bytes
7999        // ride before the next batch is reserved. Reserving every blob first
8000        // and uploading afterwards expires the earliest URLs on a real store.
8001        for batch in batch_upload_declarations(declarations) {
8002            let batch_len = batch.len();
8003            let reserved = reserve_upload_window(
8004                cfg,
8005                &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
8006                &json!({ "blobs": batch }),
8007                "prepare v2 changed-byte uploads",
8008            )?;
8009            let items = reserved
8010                .get("uploads")
8011                .and_then(Value::as_array)
8012                .ok_or_else(|| invalid_feed("v2 upload reservation response has no items"))?;
8013            if items.len() != batch_len {
8014                return Err(invalid_feed(
8015                    "v2 upload reservation response changed the requested set",
8016                ));
8017            }
8018            reserved_count += items.len();
8019            for item in items {
8020                let sha256 = item
8021                    .get("sha256")
8022                    .and_then(Value::as_str)
8023                    .ok_or_else(|| invalid_feed("v2 upload reservation has no hash"))?;
8024                let source = upload_sources
8025                    .get(sha256)
8026                    .ok_or_else(|| invalid_feed("v2 upload reservation introduced a blob"))?;
8027                let declared_bytes = item
8028                    .get("bytes")
8029                    .and_then(Value::as_u64)
8030                    .ok_or_else(|| invalid_feed("v2 upload reservation has no byte length"))?;
8031                let reservation_id = item
8032                    .get("reservation_id")
8033                    .and_then(Value::as_str)
8034                    .ok_or_else(|| invalid_feed("v2 upload reservation has no opaque id"))?;
8035                let expected_coordinates = coordinates_by_hash.get(sha256).ok_or_else(|| {
8036                    invalid_feed("v2 upload reservation has no coordinate binding")
8037                })?;
8038                let returned_coordinates = item
8039                    .get("coordinates")
8040                    .and_then(Value::as_array)
8041                    .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinates"))?;
8042                if declared_bytes != source.bytes
8043                    || !crate::ulid::is_ulid(reservation_id)
8044                    || !seen.insert(sha256.to_string())
8045                    || returned_coordinates.len() != expected_coordinates.len()
8046                    || returned_coordinates
8047                        .iter()
8048                        .zip(expected_coordinates)
8049                        .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
8050                {
8051                    return Err(invalid_feed("v2 upload reservation item is inconsistent"));
8052                }
8053                match item.get("status").and_then(Value::as_str) {
8054                    Some("upload") => {
8055                        let url = item
8056                            .get("url")
8057                            .and_then(Value::as_str)
8058                            .ok_or_else(|| invalid_feed("v2 upload reservation has no URL"))?;
8059                        pending_uploads.push(V2PendingUpload {
8060                            url: url.to_string(),
8061                            headers: item.get("headers").cloned().unwrap_or(Value::Null),
8062                            sha256: sha256.to_string(),
8063                            source,
8064                        });
8065                    }
8066                    Some("already_present") => {}
8067                    _ => return Err(invalid_feed("v2 upload reservation has an unknown status")),
8068                }
8069                references.push(json!({
8070                    "sha256": sha256,
8071                    "bytes": source.bytes,
8072                    "reservation_id": reservation_id,
8073                }));
8074            }
8075            // Stage this batch's bytes concurrently. Sequential PUTs move a real
8076            // store at a few objects a second, which both takes hours and pushes
8077            // the batch past its reservation's upload window; the object store
8078            // is content-addressed and precondition-guarded, so parallel writes
8079            // stay safe.
8080            upload_v2_batch_concurrently(cfg, store, &pending_uploads)?;
8081            pending_uploads.clear();
8082        }
8083        if reserved_count != upload_sources.len() {
8084            return Err(invalid_feed(
8085                "v2 upload reservation response changed the requested set",
8086            ));
8087        }
8088        body["blobs"] = Value::Array(references);
8089    }
8090    stage_oversized_v2_change(cfg, requested_brain, &operations, &mut body)?;
8091    let path = format!("/api/hub/brains/{requested_brain}/v2/commits");
8092    let mut candidate_hub_signer: Option<String> = None;
8093    let mut response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
8094    let bulk_preview_required = !(200..300).contains(&response.status)
8095        && response.body.as_ref().is_some_and(|value| {
8096            value.get("code").and_then(Value::as_str) == Some("bulk_preview_required")
8097                || value
8098                    .get("details")
8099                    .and_then(|details| details.get("code"))
8100                    .and_then(Value::as_str)
8101                    == Some("bulk_preview_required")
8102        });
8103    if bulk_preview_required && bulk_confirmation.is_none() {
8104        body["rebase"] = Value::String("strict".to_string());
8105        body["preview_only"] = Value::Bool(true);
8106        let preview = ensure_ok(
8107            request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
8108            "v2 bulk preview",
8109        )?;
8110        let preview_code = preview.get("code").and_then(Value::as_str);
8111        let required = preview.get("required").and_then(Value::as_bool);
8112        if preview.get("v").and_then(Value::as_u64) != Some(2)
8113            || preview.get("mutation_id").and_then(Value::as_str) != Some(mutation_id.as_str())
8114            || !matches!(
8115                preview_code,
8116                Some("bulk_preview_created" | "bulk_preview_not_required")
8117            )
8118            || required.is_none()
8119        {
8120            return Err(invalid_feed(
8121                "bulk preview response is not bound to the requested mutation",
8122            ));
8123        }
8124        if required == Some(true) {
8125            let preview_id = preview.get("bulk_preview_id").and_then(Value::as_str);
8126            let preview_digest = preview.get("bulk_preview_digest").and_then(Value::as_str);
8127            if preview_code != Some("bulk_preview_created")
8128                || preview_id.is_none_or(|value| !crate::ulid::is_ulid(value))
8129                || preview_digest.is_none_or(|value| !is_sha256(value))
8130                || preview.get("expires_at").and_then(Value::as_str).is_none()
8131                || !preview.get("impact").is_some_and(Value::is_object)
8132            {
8133                return Err(invalid_feed("bulk preview receipt is malformed"));
8134            }
8135            return Err(LinkError::BulkPreviewRequired { preview });
8136        }
8137        if preview_code != Some("bulk_preview_not_required") {
8138            return Err(invalid_feed("bulk preview requirement is inconsistent"));
8139        }
8140        // The rolling activity window can expire between the refusal and
8141        // preview. Retry once, still pinned strictly to the observed head.
8142        body.as_object_mut()
8143            .expect("v2 commit request is an object")
8144            .remove("preview_only");
8145        response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
8146    }
8147    let mut result = ensure_ok(response, "v2 sync push")?;
8148    if result.get("code").and_then(Value::as_str) == Some("proposal_queued") {
8149        if let Some(object) = result.as_object_mut() {
8150            object.insert(
8151                "sync_status".to_string(),
8152                Value::String("proposal_pending".to_string()),
8153            );
8154        }
8155        return Ok(result);
8156    }
8157    if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
8158        let request_id = result
8159            .get("request_id")
8160            .and_then(Value::as_str)
8161            .ok_or_else(|| invalid_feed("self-custody response has no request id"))?
8162            .to_string();
8163        let challenge = result
8164            .get("signing_challenge")
8165            .ok_or_else(|| invalid_feed("self-custody response has no signing challenge"))?;
8166        let mut expected_candidate = remote.clone();
8167        let mut expected_candidate_assets = remote_assets.clone();
8168        apply_generated_v2_operations(
8169            &operations,
8170            &local_assets,
8171            &mut expected_candidate,
8172            &mut expected_candidate_assets,
8173        )?;
8174        let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
8175            cfg,
8176            &head,
8177            &expected_candidate,
8178            &expected_candidate_assets,
8179            &mutation_id,
8180            &v2_signed_request_view(&body, &operations),
8181            challenge,
8182        )?;
8183        body["signing_challenge_id"] = Value::String(challenge_id);
8184        body["signature_base64url"] = Value::String(signature);
8185        candidate_hub_signer = Some(actor_signer);
8186        result = ensure_ok(
8187            request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
8188            "v2 self-custody commit",
8189        )?;
8190    }
8191    let refreshed = v2_verified_head(cfg, requested_brain)?
8192        .ok_or_else(|| invalid_feed("v2 head disappeared after commit"))?;
8193    if candidate_hub_signer
8194        .as_ref()
8195        .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
8196    {
8197        return Err(invalid_feed(
8198            "self-custody actor signer differs from the committed hub pointer signer",
8199        ));
8200    }
8201    let accepted_hash = result.get("commit_hash").and_then(Value::as_str);
8202    if refreshed
8203        .pointer
8204        .as_ref()
8205        .map(|pointer| pointer.commit_hash.as_str())
8206        != accepted_hash
8207    {
8208        return Err(LinkError::RemoteAdvancedDuringSync);
8209    }
8210    ensure_v2_view_compatible(&refreshed, baseline.as_ref())?;
8211    let rebased = result
8212        .get("rebased")
8213        .and_then(Value::as_bool)
8214        .ok_or_else(|| invalid_feed("v2 commit receipt has no rebase result"))?;
8215    let (refreshed_files, refreshed_assets) = if rebased {
8216        (
8217            files_for_v2_view(
8218                &refreshed,
8219                v2_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
8220            ),
8221            v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
8222        )
8223    } else {
8224        let asset_changed = apply_generated_v2_operations(
8225            &operations,
8226            &local_assets,
8227            &mut remote,
8228            &mut remote_assets,
8229        )?;
8230        let assets = if asset_changed {
8231            // Asset leaf hashes are server-generated signed coordinates; only
8232            // asset-changing commits need this usually tiny post-commit read.
8233            v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?
8234        } else {
8235            remote_assets
8236        };
8237        (remote, assets)
8238    };
8239    let mut final_local = v2_local_files(store)?;
8240    remove_scoped_projection(&refreshed, baseline.as_ref(), &mut final_local)?;
8241    let final_assets = v2_local_asset_records(store)?;
8242    let local_dirty = final_local.riding != local_view.riding
8243        || !v2_riding_matches_remote(&final_local.riding, &refreshed_files, |path| {
8244            final_local.policy.keeps_home(path)
8245        })
8246        || final_assets != local_assets
8247        || !v2_asset_records_match_remote(&final_assets, &refreshed_assets);
8248    let next = v2_baseline_from_head(
8249        cfg,
8250        &refreshed,
8251        refreshed_files,
8252        refreshed_assets,
8253        Some(&final_local),
8254        Some(&checkout_pseudonym),
8255    )?;
8256    let split_count = next.remote_copy_remains.len();
8257    accept_v2_head(cfg, &refreshed)?;
8258    if !local_dirty {
8259        refresh_scoped_view_marker(store, &refreshed, next.files.len())?;
8260        save_v2_baseline(cfg, &refreshed.brain_id, &store.root, &next)?;
8261    }
8262    if let Some(object) = result.as_object_mut() {
8263        object.insert(
8264            "local_policy".to_string(),
8265            json!({ "remote_copy_remains": split_count }),
8266        );
8267        object.insert(
8268            "sync_status".to_string(),
8269            Value::String(if local_dirty {
8270                "remote_committed_local_dirty".to_string()
8271            } else {
8272                "synced".to_string()
8273            }),
8274        );
8275    }
8276    Ok(result)
8277}
8278
8279/// Negotiate v2 and send only local changes. A v1 hub retains the existing
8280/// whole-snapshot behavior until its brain advertises the new profile.
8281pub fn sync_push_incremental(cfg: &HubConfig, brain: &str, store: &Store) -> LinkResult<Value> {
8282    sync_push_incremental_with_policy(cfg, brain, store, false)
8283}
8284
8285/// The explicit `.sevralocal` adoption form. Ordinary sync never uploads a
8286/// path that became eligible because local policy was weakened or removed.
8287pub fn sync_push_incremental_with_policy(
8288    cfg: &HubConfig,
8289    brain: &str,
8290    store: &Store,
8291    resume_local_policy: bool,
8292) -> LinkResult<Value> {
8293    sync_push_incremental_with_options(cfg, brain, store, resume_local_policy, None)
8294}
8295
8296/// Incremental push with both local-policy adoption and an optional exact
8297/// permissioned bulk-preview confirmation.
8298pub fn sync_push_incremental_with_options(
8299    cfg: &HubConfig,
8300    brain: &str,
8301    store: &Store,
8302    resume_local_policy: bool,
8303    bulk_confirmation: Option<&V2BulkConfirmation>,
8304) -> LinkResult<Value> {
8305    sync_push_incremental_with_controls(
8306        cfg,
8307        brain,
8308        store,
8309        resume_local_policy,
8310        bulk_confirmation,
8311        &[],
8312        None,
8313    )
8314}
8315
8316/// Incremental push with explicit permissioned current-host withdrawal.
8317pub fn sync_push_incremental_with_controls(
8318    cfg: &HubConfig,
8319    brain: &str,
8320    store: &Store,
8321    resume_local_policy: bool,
8322    bulk_confirmation: Option<&V2BulkConfirmation>,
8323    withdrawal_paths: &[String],
8324    withdrawal_reason: Option<&str>,
8325) -> LinkResult<Value> {
8326    require_safe_ref(brain)?;
8327    if let Some(head) = v2_verified_head(cfg, brain)? {
8328        return v2_sync_push(
8329            cfg,
8330            brain,
8331            store,
8332            head,
8333            V2SyncPushOptions {
8334                resume_local_policy,
8335                bulk_confirmation,
8336                resolution: None,
8337                pulled: None,
8338                withdrawal_paths,
8339                withdrawal_reason,
8340            },
8341        );
8342    }
8343    if !withdrawal_paths.is_empty() {
8344        return Err(LinkError::InvalidPack {
8345            message: "explicit withdrawal requires a link.md v2 brain".to_string(),
8346        });
8347    }
8348    legacy_sync_push_incremental(cfg, brain, store, resume_local_policy, bulk_confirmation)
8349}
8350
8351/// Whether this exact hub/brain/path coordinate has an accepted v2 sync
8352/// baseline. CLI dispatch uses this to keep checkout identity sticky even when
8353/// a damaged `DB.md` makes strict store-open fail.
8354pub fn has_v2_sync_baseline(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<bool> {
8355    require_safe_ref(brain)?;
8356    Ok(load_v2_baseline(cfg, brain, checkout)?.is_some())
8357}
8358
8359#[cfg(windows)]
8360fn legacy_sync_push_incremental(
8361    _cfg: &HubConfig,
8362    _brain: &str,
8363    _store: &Store,
8364    _resume_local_policy: bool,
8365    _bulk_confirmation: Option<&V2BulkConfirmation>,
8366) -> LinkResult<Value> {
8367    Err(LinkError::UnsupportedPlatform {
8368        operation: "legacy v1 whole-snapshot push on Windows; upgrade the brain to link.md v2",
8369    })
8370}
8371
8372#[cfg(not(windows))]
8373fn legacy_sync_push_incremental(
8374    cfg: &HubConfig,
8375    brain: &str,
8376    store: &Store,
8377    resume_local_policy: bool,
8378    bulk_confirmation: Option<&V2BulkConfirmation>,
8379) -> LinkResult<Value> {
8380    if resume_local_policy || bulk_confirmation.is_some() {
8381        return Err(LinkError::InvalidPack {
8382            message: "v2 sync options require a link.md v2 brain".to_string(),
8383        });
8384    }
8385    let files = collect_push_files(store)?;
8386    sync_push(cfg, brain, &files)
8387}
8388
8389/// One explicit resolution for a previously preserved private conflict bundle.
8390#[derive(Debug, Clone)]
8391pub enum V2ConflictChoice {
8392    KeepLocal,
8393    TakeRemote,
8394    From(PathBuf),
8395}
8396
8397fn load_v2_conflict_plan(store: &Store, bundle: &str) -> LinkResult<V2ConflictPlan> {
8398    if !crate::ulid::is_ulid(bundle) {
8399        return Err(LinkError::InvalidPack {
8400            message: "conflict bundle must be a lowercase ULID".to_string(),
8401        });
8402    }
8403    let bytes = store.read_bounded(&v2_conflict_relative(bundle, "plan.json"), 1024 * 1024)?;
8404    let plan: V2ConflictPlan = serde_json::from_slice(&bytes)
8405        .map_err(|_| invalid_feed("private conflict plan is corrupt"))?;
8406    if plan.v != 2
8407        || plan.class != "content_resolution_required"
8408        || plan.bundle != bundle
8409        || !crate::ulid::is_ulid(&plan.brain)
8410        || plan.files.is_empty()
8411        || plan.files.len() > 100
8412        || plan.files.iter().any(|file| {
8413            crate::linkmd_v2::normalize_path(&file.path).is_err()
8414                || [&file.base, &file.local, &file.remote]
8415                    .into_iter()
8416                    .any(|coordinate| {
8417                        coordinate
8418                            .sha256
8419                            .as_deref()
8420                            .is_some_and(|hash| !is_sha256(hash))
8421                            || coordinate.file.as_deref().is_some_and(|name| {
8422                                name.starts_with('/')
8423                                    || name
8424                                        .split('/')
8425                                        .any(|part| part.is_empty() || part == "." || part == "..")
8426                            })
8427                    })
8428        })
8429    {
8430        return Err(invalid_feed("private conflict plan failed validation"));
8431    }
8432    Ok(plan)
8433}
8434
8435/// Inspect or prune private conflict-control state. Ordinary pruning removes
8436/// only expired completed bundles and interrupted bundles with no `plan.json`;
8437/// corrupt completed plans fail closed. `all` is an explicit local discard of
8438/// every well-addressed bundle and never changes hosted brain data.
8439pub fn sync_conflicts(checkout: &Path, prune: bool, all: bool) -> LinkResult<Value> {
8440    require_hardened_filesystem("private conflict maintenance")?;
8441    if all && !prune {
8442        return Err(LinkError::InvalidPack {
8443            message: "discarding all conflict bundles requires prune=true".to_string(),
8444        });
8445    }
8446    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8447        message: format!("conflict checkout is not a valid db.md store: {error}"),
8448    })?;
8449    let _transaction = store.transaction()?;
8450    let root = Path::new(".dbmd/conflicts");
8451    let names = match store.directory_names(root) {
8452        Ok(names) => names,
8453        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
8454        Err(error) => return Err(error.into()),
8455    };
8456    let now = SystemTime::now()
8457        .duration_since(UNIX_EPOCH)
8458        .unwrap_or_default()
8459        .as_secs();
8460    let mut bundles = Vec::new();
8461    let mut pruned = 0_u64;
8462    for name in names {
8463        let Some(bundle) = name.to_str().filter(|value| crate::ulid::is_ulid(value)) else {
8464            continue;
8465        };
8466        let plan_path = v2_conflict_relative(bundle, "plan.json");
8467        let plan_exists = store.regular_file_exists(&plan_path)?;
8468        let expired = if plan_exists {
8469            match load_v2_conflict_plan(&store, bundle) {
8470                Ok(plan) => plan.expires_unix < now,
8471                Err(error) if all => {
8472                    let _ = error;
8473                    true
8474                }
8475                Err(error) => return Err(error),
8476            }
8477        } else {
8478            true
8479        };
8480        if prune && (all || expired) {
8481            store.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
8482            pruned += 1;
8483            continue;
8484        }
8485        bundles.push(json!({
8486            "bundle": bundle,
8487            "complete": plan_exists,
8488            "expired": expired,
8489        }));
8490    }
8491    Ok(json!({
8492        "v": 2,
8493        "class": "private_conflict_state",
8494        "bundles": bundles.len(),
8495        "pruned": pruned,
8496        "items": bundles,
8497    }))
8498}
8499
8500/// Resolve one exact private conflict plan. Drift never lowers a precondition:
8501/// the remote head/view and every original local coordinate are rechecked
8502/// before either an explicit local install or a fresh normal commit.
8503pub fn sync_resolve_conflict(
8504    cfg: &HubConfig,
8505    checkout: &Path,
8506    bundle: &str,
8507    choice: V2ConflictChoice,
8508    bulk_confirmation: Option<&V2BulkConfirmation>,
8509) -> LinkResult<Value> {
8510    require_hardened_filesystem("conflict resolution")?;
8511    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8512        message: format!("conflict checkout is not a valid db.md store: {error}"),
8513    })?;
8514    let plan = load_v2_conflict_plan(&store, bundle)?;
8515    if plan.origin != normalized_origin(&cfg.hub)? {
8516        return Err(invalid_feed(
8517            "conflict bundle belongs to another hub origin",
8518        ));
8519    }
8520    let now = SystemTime::now()
8521        .duration_since(UNIX_EPOCH)
8522        .unwrap_or_default()
8523        .as_secs();
8524    if now > plan.expires_unix {
8525        return Err(LinkError::InvalidPack {
8526            message: "conflict bundle expired; rerun sync to obtain current coordinates"
8527                .to_string(),
8528        });
8529    }
8530    let head = v2_verified_head(cfg, &plan.brain)?
8531        .ok_or_else(|| invalid_feed("conflict brain no longer advertises v2"))?;
8532    let pointer = head.pointer.as_ref();
8533    if pointer.map_or(0, |value| value.seq) != plan.remote_seq
8534        || pointer.map(|value| value.commit_hash.as_str()) != plan.remote_commit.as_deref()
8535        || pointer.and_then(|value| value.content_root.as_deref())
8536            != plan.remote_content_root.as_deref()
8537        || head.view_kind != plan.view_kind
8538        || head.view_revision != plan.view_revision
8539    {
8540        return Err(LinkError::RemoteAdvancedDuringSync);
8541    }
8542
8543    // A resolution never overwrites a post-bundle local edit.
8544    for file in &plan.files {
8545        let actual = match store.regular_file_exists(Path::new(&file.path))? {
8546            true => Some(content_sha256(&store.read_bounded(
8547                Path::new(&file.path),
8548                file.local.bytes.unwrap_or(MAX_STORE_BYTES),
8549            )?)),
8550            false => None,
8551        };
8552        if actual.as_deref() != file.local.sha256.as_deref() {
8553            return Err(LinkError::InvalidPack {
8554                message: format!(
8555                    "local conflict path `{}` changed after the bundle was created",
8556                    file.path
8557                ),
8558            });
8559        }
8560    }
8561
8562    let from_source = match &choice {
8563        V2ConflictChoice::From(source) => Some(source.clone()),
8564        _ => None,
8565    };
8566    let result = match choice {
8567        V2ConflictChoice::TakeRemote => {
8568            if bulk_confirmation.is_some() {
8569                return Err(LinkError::InvalidPack {
8570                    message: "bulk confirmation applies to keep-local/from commits, not a local take-remote install".to_string(),
8571                });
8572            }
8573            // Re-read the exact current manifest so every selected remote byte
8574            // retains its signed inclusion proof. The private bundle is useful
8575            // for inspection, but is never promoted into live state as trust.
8576            let current_remote =
8577                files_for_v2_view(&head, v2_manifest(cfg, &plan.brain, head.pointer.as_ref())?);
8578            let _ = v2_take_remote_selection(&plan.files, &current_remote)?;
8579            let selected = plan
8580                .files
8581                .iter()
8582                .map(|file| file.path.clone())
8583                .collect::<std::collections::BTreeSet<_>>();
8584            serde_json::to_value(
8585                v2_sync_pull_with_resolution(
8586                    cfg,
8587                    &plan.brain,
8588                    head,
8589                    Some(checkout),
8590                    Some(&selected),
8591                )?
8592                .report,
8593            )
8594            .map_err(|_| invalid_feed("could not serialize conflict pull receipt"))?
8595        }
8596        V2ConflictChoice::KeepLocal | V2ConflictChoice::From(_) => {
8597            if let Some(source) = from_source.as_ref() {
8598                if plan.files.len() != 1 {
8599                    return Err(LinkError::InvalidPack {
8600                        message: "--from requires a bundle with exactly one conflict".to_string(),
8601                    });
8602                }
8603                let candidate = crate::fsx::read_bounded_nofollow(source, MAX_STORE_BYTES)?;
8604                if std::str::from_utf8(&candidate).is_err() {
8605                    return Err(LinkError::NotUtf8 {
8606                        path: source.display().to_string(),
8607                    });
8608                }
8609                store.write_atomic(Path::new(&plan.files[0].path), &candidate)?;
8610            }
8611            let refreshed_store =
8612                Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8613                    message: format!("resolved checkout is not a valid db.md store: {error}"),
8614                })?;
8615            let mut overrides = std::collections::BTreeMap::new();
8616            for file in &plan.files {
8617                let selected_local = match refreshed_store
8618                    .regular_file_exists(Path::new(&file.path))?
8619                {
8620                    true => Some(content_sha256(
8621                        &refreshed_store.read_bounded(Path::new(&file.path), MAX_STORE_BYTES)?,
8622                    )),
8623                    false => None,
8624                };
8625                overrides.insert(
8626                    file.path.clone(),
8627                    V2ResolutionOverride {
8628                        expected_remote: file.remote.sha256.clone(),
8629                        selected_local,
8630                    },
8631                );
8632            }
8633            v2_sync_push(
8634                cfg,
8635                &plan.brain,
8636                &refreshed_store,
8637                head,
8638                V2SyncPushOptions {
8639                    resume_local_policy: true,
8640                    bulk_confirmation,
8641                    resolution: Some(&overrides),
8642                    pulled: None,
8643                    withdrawal_paths: &[],
8644                    withdrawal_reason: None,
8645                },
8646            )?
8647        }
8648    };
8649
8650    if result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
8651        let installed = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8652            message: format!("resolved checkout is not a valid db.md store: {error}"),
8653        })?;
8654        installed.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
8655    }
8656    Ok(json!({
8657        "v": 2,
8658        "class": "auto_converged",
8659        "bundle": bundle,
8660        "receipt": result,
8661    }))
8662}
8663
8664/// Converge one established permissioned-v2 checkout in both directions.
8665///
8666/// The pull half first performs the ordinary three-way conflict check and
8667/// crash-safe install against the accepted baseline. The push half then opens
8668/// that exact installed checkout, takes its local transaction guard, computes
8669/// a fresh three-way delta, and submits it with mandatory preconditions. A
8670/// remote movement between halves is therefore a normal rebase/conflict input,
8671/// never a blind overwrite. Legacy v1 remains explicit pull-only/push-only so
8672/// its whole-pack replacement semantics are never presented as granular
8673/// convergence.
8674pub fn sync_converge(
8675    cfg: &HubConfig,
8676    brain: &str,
8677    checkout: &Path,
8678    resume_local_policy: bool,
8679) -> LinkResult<Value> {
8680    sync_converge_with_options(cfg, brain, checkout, resume_local_policy, None)
8681}
8682
8683/// Bidirectional convergence with optional exact bulk-preview confirmation.
8684pub fn sync_converge_with_options(
8685    cfg: &HubConfig,
8686    brain: &str,
8687    checkout: &Path,
8688    resume_local_policy: bool,
8689    bulk_confirmation: Option<&V2BulkConfirmation>,
8690) -> LinkResult<Value> {
8691    sync_converge_with_controls(
8692        cfg,
8693        brain,
8694        checkout,
8695        resume_local_policy,
8696        bulk_confirmation,
8697        &[],
8698        None,
8699    )
8700}
8701
8702/// Bidirectional convergence with optional exact withdrawal intents.
8703pub fn sync_converge_with_controls(
8704    cfg: &HubConfig,
8705    brain: &str,
8706    checkout: &Path,
8707    resume_local_policy: bool,
8708    bulk_confirmation: Option<&V2BulkConfirmation>,
8709    withdrawal_paths: &[String],
8710    withdrawal_reason: Option<&str>,
8711) -> LinkResult<Value> {
8712    require_hardened_filesystem("bidirectional sync")?;
8713    require_safe_ref(brain)?;
8714    let head = v2_verified_head(cfg, brain)?.ok_or_else(|| LinkError::InvalidPack {
8715        message:
8716            "bidirectional sync requires link.md v2; use --pull-only or --push-only for a legacy brain"
8717                .to_string(),
8718    })?;
8719    let pulled = v2_sync_pull_with_resolution(cfg, brain, head, Some(checkout), None)?;
8720    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8721        message: format!("installed v2 checkout is not a valid db.md store: {error}"),
8722    })?;
8723    let _transaction = store.transaction()?;
8724    let pulled_report = pulled.report.clone();
8725    let pulled_head = pulled.head.clone();
8726    let mut result = v2_sync_push(
8727        cfg,
8728        brain,
8729        &store,
8730        pulled_head,
8731        V2SyncPushOptions {
8732            resume_local_policy,
8733            bulk_confirmation,
8734            resolution: None,
8735            pulled: Some(pulled),
8736            withdrawal_paths,
8737            withdrawal_reason,
8738        },
8739    )?;
8740    if let Some(object) = result.as_object_mut() {
8741        object.insert("pulled_files".to_string(), json!(pulled_report.files));
8742        object.insert("checkout".to_string(), Value::String(pulled_report.dest));
8743        object.insert(
8744            "mode".to_string(),
8745            Value::String("bidirectional".to_string()),
8746        );
8747    }
8748    Ok(result)
8749}
8750
8751/// Pull the granted slice of `brain` to `out` (default: `./<slug>`). Every
8752/// exported path is safety-gated before it touches disk; files are written
8753/// atomically; nothing local is ever deleted (locals the export lacks are
8754/// *reported* in `extra_local` instead). Returns the report; rebuilding the
8755/// local index catalog afterwards is the caller's (cheap, optional) step.
8756pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
8757    require_hardened_filesystem("sync pull")?;
8758    require_safe_ref(brain)?;
8759    if let Some(head) = v2_verified_head(cfg, brain)? {
8760        return v2_sync_pull(cfg, brain, head, out);
8761    }
8762    legacy_sync_pull(cfg, brain, out)
8763}
8764
8765#[cfg(windows)]
8766fn legacy_sync_pull(_cfg: &HubConfig, _brain: &str, _out: Option<&Path>) -> LinkResult<PullReport> {
8767    Err(LinkError::UnsupportedPlatform {
8768        operation: "legacy v1 whole-snapshot pull on Windows; upgrade the brain to link.md v2",
8769    })
8770}
8771
8772#[cfg(not(windows))]
8773fn legacy_sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
8774    let remote = verified_remote_head(cfg, brain, false)?;
8775    if !remote.head.verified {
8776        return Err(invalid_feed(
8777            "the grant exposes head movement but no signed snapshot; refusing an unverifiable pull",
8778        ));
8779    }
8780    let snapshot_hash = remote.head.feed_hash.as_deref().unwrap_or("none");
8781    let path = format!(
8782        "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={snapshot_hash}",
8783        remote.head.seq
8784    );
8785    let body = ensure_ok(
8786        request(cfg, "GET", &path, None, Auth::Required)?,
8787        "sync pull",
8788    )?;
8789    if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
8790        || body.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
8791    {
8792        return Err(invalid_feed(
8793            "export response is not bound to the verified snapshot token",
8794        ));
8795    }
8796
8797    let remote_slug = body
8798        .get("slug")
8799        .and_then(Value::as_str)
8800        .filter(|slug| is_safe_slug(slug));
8801    let slug = remote_slug
8802        .or_else(|| is_safe_slug(brain).then_some(brain))
8803        .unwrap_or("brain")
8804        .to_string();
8805    let brain_id = body
8806        .get("brain")
8807        .and_then(Value::as_str)
8808        .unwrap_or(&remote.head.brain)
8809        .to_string();
8810    if brain_id != remote.head.brain {
8811        return Err(invalid_feed(
8812            "export response names a different brain than the verified head",
8813        ));
8814    }
8815    let head_seq = remote.head.seq;
8816    let dest: PathBuf = match out {
8817        Some(p) => p.to_path_buf(),
8818        None => PathBuf::from(&slug),
8819    };
8820    let entries = if head_seq == 0 {
8821        let files = body
8822            .get("files")
8823            .and_then(Value::as_array)
8824            .ok_or_else(|| invalid_feed("empty snapshot export did not carry an empty manifest"))?;
8825        if !files.is_empty() || body.get("url").is_some() {
8826            return Err(invalid_feed(
8827                "empty signed feed cannot authorize non-empty exported content",
8828            ));
8829        }
8830        Vec::new()
8831    } else {
8832        let signed_head = remote
8833            .head_entry
8834            .as_ref()
8835            .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
8836        let expected = &signed_head.entry.pack_sha256;
8837        if !is_sha256(expected) {
8838            return Err(invalid_feed(
8839                "signed head carries an invalid snapshot pack digest",
8840            ));
8841        }
8842        if let Some(url) = body.get("url").and_then(Value::as_str) {
8843            if body.get("sha256").and_then(Value::as_str) != Some(expected.as_str()) {
8844                return Err(invalid_feed(
8845                    "export pack digest does not match the signed head entry",
8846                ));
8847            }
8848            let bytes = get_presigned(cfg, url)?;
8849            let actual = format!("{:x}", Sha256::digest(&bytes));
8850            if actual != *expected {
8851                return Err(LinkError::InvalidPack {
8852                    message: "downloaded pack does not match the signed snapshot digest"
8853                        .to_string(),
8854                });
8855            }
8856            let entries = parse_store_pack(bytes)?;
8857            if signed_head.entry.kind == "push" {
8858                verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
8859            }
8860            entries
8861        } else {
8862            if signed_head.entry.kind != "push" {
8863                return Err(invalid_feed(
8864                    "delta snapshots must export the exact signed pack",
8865                ));
8866            }
8867            let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
8868                invalid_feed("verified snapshot export carried neither a pack nor files")
8869            })?;
8870            let mut entries = Vec::with_capacity(files.len());
8871            for file in files {
8872                let path = file
8873                    .get("path")
8874                    .and_then(Value::as_str)
8875                    .ok_or_else(|| invalid_feed("exported file path is not a string"))?;
8876                let content = file
8877                    .get("content")
8878                    .and_then(Value::as_str)
8879                    .ok_or_else(|| invalid_feed("exported inline content is not UTF-8 text"))?;
8880                entries.push((path.to_string(), content.as_bytes().to_vec()));
8881            }
8882            verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
8883            entries
8884        }
8885    };
8886
8887    // Gate the complete manifest before the first filesystem mutation.
8888    let mut seen = std::collections::HashSet::new();
8889    for (path, _) in &entries {
8890        if !safe_store_rel_path(path) {
8891            return Err(LinkError::UnsafePath { path: path.clone() });
8892        }
8893        if !seen.insert(path) {
8894            return Err(LinkError::InvalidPack {
8895                message: format!("duplicate path `{path}`"),
8896            });
8897        }
8898    }
8899    // Compute divergence against the still-live tree. The staged clone keeps
8900    // these extra locals byte-for-byte while overlaying the signed snapshot.
8901    let pulled: std::collections::BTreeSet<&str> =
8902        entries.iter().map(|(p, _)| p.as_str()).collect();
8903    let mut extra_local = Vec::new();
8904    if let Ok(store) = Store::open(&dest) {
8905        if let Ok(walked) = store.walk() {
8906            for rel in walked {
8907                let rel_str = rel.to_string_lossy().replace('\\', "/");
8908                if !pulled.contains(rel_str.as_str()) {
8909                    extra_local.push(rel_str);
8910                }
8911            }
8912        }
8913    }
8914    #[cfg(unix)]
8915    install_pulled_snapshot(&dest, &entries)?;
8916
8917    Ok(PullReport {
8918        brain: brain_id,
8919        slug,
8920        head_seq,
8921        files: entries.len(),
8922        dest: dest.to_string_lossy().into_owned(),
8923        extra_local,
8924        sync_status: "synced".to_string(),
8925    })
8926}
8927
8928#[cfg(unix)]
8929fn c_name(bytes: &[u8], display: &str) -> LinkResult<std::ffi::CString> {
8930    std::ffi::CString::new(bytes).map_err(|_| LinkError::UnsafePath {
8931        path: display.to_string(),
8932    })
8933}
8934
8935#[cfg(unix)]
8936fn open_dir_at(
8937    parent: std::os::fd::RawFd,
8938    name: &std::ffi::CStr,
8939    display: &str,
8940) -> LinkResult<std::fs::File> {
8941    use std::os::fd::FromRawFd as _;
8942    let fd = unsafe {
8943        libc::openat(
8944            parent,
8945            name.as_ptr(),
8946            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
8947        )
8948    };
8949    if fd < 0 {
8950        return Err(LinkError::UnsafePath {
8951            path: display.to_string(),
8952        });
8953    }
8954    Ok(unsafe { std::fs::File::from_raw_fd(fd) })
8955}
8956
8957/// Open (and, where absent, create) a directory path without following a
8958/// symlink in any component. The returned directory capability remains bound
8959/// to the opened inode even if an attacker renames or replaces an ancestor.
8960#[cfg(unix)]
8961fn open_dir_path_nofollow(path: &Path, create: bool) -> LinkResult<std::fs::File> {
8962    use std::os::fd::AsRawFd as _;
8963
8964    // macOS exposes a few system-owned compatibility symlinks at the root.
8965    // Normalize only those fixed OS aliases; never canonicalize an arbitrary
8966    // caller-controlled ancestor.
8967    #[cfg(target_os = "macos")]
8968    let normalized = [("/var", "/private/var"), ("/tmp", "/private/tmp")]
8969        .into_iter()
8970        .find_map(|(alias, real)| {
8971            path.strip_prefix(alias)
8972                .ok()
8973                .map(|rest| Path::new(real).join(rest))
8974        })
8975        .unwrap_or_else(|| path.to_path_buf());
8976    #[cfg(not(target_os = "macos"))]
8977    let normalized = path.to_path_buf();
8978
8979    let start = if normalized.is_absolute() {
8980        std::fs::File::open("/")?
8981    } else {
8982        std::fs::File::open(".")?
8983    };
8984    let mut directory = start;
8985    for component in normalized.components() {
8986        use std::path::Component;
8987        let name = match component {
8988            Component::RootDir | Component::CurDir => continue,
8989            Component::Normal(name) => name,
8990            Component::ParentDir | Component::Prefix(_) => {
8991                return Err(LinkError::UnsafePath {
8992                    path: path.display().to_string(),
8993                });
8994            }
8995        };
8996        use std::os::unix::ffi::OsStrExt as _;
8997        let name = c_name(name.as_bytes(), &path.display().to_string())?;
8998        if create {
8999            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9000            if made != 0 {
9001                let error = std::io::Error::last_os_error();
9002                if error.raw_os_error() != Some(libc::EEXIST) {
9003                    return Err(error.into());
9004                }
9005            }
9006        }
9007        directory = open_dir_at(directory.as_raw_fd(), &name, &path.display().to_string())?;
9008    }
9009    Ok(directory)
9010}
9011
9012#[cfg(unix)]
9013fn open_or_create_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
9014    open_dir_path_nofollow(path, true)
9015}
9016
9017#[cfg(unix)]
9018fn open_existing_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
9019    open_dir_path_nofollow(path, false)
9020}
9021
9022#[cfg(unix)]
9023fn entry_is_dir_at(parent: std::os::fd::RawFd, name: &std::ffi::CStr) -> LinkResult<Option<bool>> {
9024    let mut stat: libc::stat = unsafe { std::mem::zeroed() };
9025    let result =
9026        unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
9027    if result == 0 {
9028        return Ok(Some((stat.st_mode & libc::S_IFMT) == libc::S_IFDIR));
9029    }
9030    let error = std::io::Error::last_os_error();
9031    if error.kind() == std::io::ErrorKind::NotFound {
9032        Ok(None)
9033    } else {
9034        Err(error.into())
9035    }
9036}
9037
9038#[cfg(unix)]
9039fn create_dir_exclusive_at(
9040    parent: std::os::fd::RawFd,
9041    name: &std::ffi::CStr,
9042    display: &str,
9043) -> LinkResult<std::fs::File> {
9044    let made = unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) };
9045    if made != 0 {
9046        return Err(LinkError::UnsafePath {
9047            path: display.to_string(),
9048        });
9049    }
9050    open_dir_at(parent, name, display)
9051}
9052
9053#[cfg(unix)]
9054fn directory_entry_names(directory: &std::fs::File) -> LinkResult<Vec<std::ffi::CString>> {
9055    use std::os::fd::AsRawFd as _;
9056
9057    let duplicate = unsafe { libc::dup(directory.as_raw_fd()) };
9058    if duplicate < 0 {
9059        return Err(std::io::Error::last_os_error().into());
9060    }
9061    let stream = unsafe { libc::fdopendir(duplicate) };
9062    if stream.is_null() {
9063        let error = std::io::Error::last_os_error();
9064        unsafe {
9065            libc::close(duplicate);
9066        }
9067        return Err(error.into());
9068    }
9069    let mut names = Vec::new();
9070    loop {
9071        let entry = unsafe { libc::readdir(stream) };
9072        if entry.is_null() {
9073            break;
9074        }
9075        let raw = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
9076        if raw.to_bytes() != b"." && raw.to_bytes() != b".." {
9077            names.push(raw.to_owned());
9078        }
9079    }
9080    if unsafe { libc::closedir(stream) } != 0 {
9081        return Err(std::io::Error::last_os_error().into());
9082    }
9083    Ok(names)
9084}
9085
9086/// Remove an entry tree relative to a held directory capability. Symlinks are
9087/// unlinked, never traversed, even when an old mirror contains hostile names.
9088#[cfg(unix)]
9089fn remove_tree_at(
9090    parent: std::os::fd::RawFd,
9091    name: &std::ffi::CStr,
9092    display: &str,
9093) -> LinkResult<()> {
9094    use std::os::fd::AsRawFd as _;
9095
9096    match entry_is_dir_at(parent, name)? {
9097        None => return Ok(()),
9098        Some(false) => {
9099            if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } != 0 {
9100                return Err(std::io::Error::last_os_error().into());
9101            }
9102        }
9103        Some(true) => {
9104            let directory = open_dir_at(parent, name, display)?;
9105            for child in directory_entry_names(&directory)? {
9106                let child_display =
9107                    format!("{display}/{}", String::from_utf8_lossy(child.to_bytes()));
9108                remove_tree_at(directory.as_raw_fd(), &child, &child_display)?;
9109            }
9110            drop(directory);
9111            if unsafe { libc::unlinkat(parent, name.as_ptr(), libc::AT_REMOVEDIR) } != 0 {
9112                return Err(std::io::Error::last_os_error().into());
9113            }
9114        }
9115    }
9116    Ok(())
9117}
9118
9119/// Clone a live destination into a private sibling stage without following
9120/// any symlink. Regular files are streamed between held descriptors; symlinks
9121/// are reproduced as links, never opened. Special files are refused.
9122#[cfg(unix)]
9123fn clone_tree_contents(
9124    source: &std::fs::File,
9125    destination: &std::fs::File,
9126    display: &str,
9127) -> LinkResult<()> {
9128    use std::os::fd::{AsRawFd as _, FromRawFd as _};
9129
9130    for name in directory_entry_names(source)? {
9131        let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
9132        let mut stat: libc::stat = unsafe { std::mem::zeroed() };
9133        if unsafe {
9134            libc::fstatat(
9135                source.as_raw_fd(),
9136                name.as_ptr(),
9137                &mut stat,
9138                libc::AT_SYMLINK_NOFOLLOW,
9139            )
9140        } != 0
9141        {
9142            return Err(std::io::Error::last_os_error().into());
9143        }
9144        match stat.st_mode & libc::S_IFMT {
9145            libc::S_IFDIR => {
9146                if unsafe {
9147                    libc::mkdirat(destination.as_raw_fd(), name.as_ptr(), stat.st_mode & 0o777)
9148                } != 0
9149                {
9150                    return Err(std::io::Error::last_os_error().into());
9151                }
9152                let source_child = open_dir_at(source.as_raw_fd(), &name, &child_display)?;
9153                let destination_child =
9154                    open_dir_at(destination.as_raw_fd(), &name, &child_display)?;
9155                clone_tree_contents(&source_child, &destination_child, &child_display)?;
9156                destination_child.sync_all()?;
9157            }
9158            libc::S_IFREG => {
9159                let source_fd = unsafe {
9160                    libc::openat(
9161                        source.as_raw_fd(),
9162                        name.as_ptr(),
9163                        libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9164                    )
9165                };
9166                if source_fd < 0 {
9167                    return Err(std::io::Error::last_os_error().into());
9168                }
9169                let destination_fd = unsafe {
9170                    libc::openat(
9171                        destination.as_raw_fd(),
9172                        name.as_ptr(),
9173                        libc::O_WRONLY
9174                            | libc::O_CREAT
9175                            | libc::O_EXCL
9176                            | libc::O_CLOEXEC
9177                            | libc::O_NOFOLLOW,
9178                        (stat.st_mode & 0o777) as libc::c_uint,
9179                    )
9180                };
9181                if destination_fd < 0 {
9182                    unsafe {
9183                        libc::close(source_fd);
9184                    }
9185                    return Err(std::io::Error::last_os_error().into());
9186                }
9187                let mut input = unsafe { std::fs::File::from_raw_fd(source_fd) };
9188                let mut output = unsafe { std::fs::File::from_raw_fd(destination_fd) };
9189                std::io::copy(&mut input, &mut output)?;
9190                output.sync_all()?;
9191            }
9192            libc::S_IFLNK => {
9193                let mut target = vec![0_u8; 4097];
9194                let length = unsafe {
9195                    libc::readlinkat(
9196                        source.as_raw_fd(),
9197                        name.as_ptr(),
9198                        target.as_mut_ptr().cast(),
9199                        target.len(),
9200                    )
9201                };
9202                if length < 0 || length as usize >= target.len() {
9203                    return Err(LinkError::UnsafePath {
9204                        path: child_display,
9205                    });
9206                }
9207                target.truncate(length as usize);
9208                let target = c_name(&target, &child_display)?;
9209                if unsafe {
9210                    libc::symlinkat(target.as_ptr(), destination.as_raw_fd(), name.as_ptr())
9211                } != 0
9212                {
9213                    return Err(std::io::Error::last_os_error().into());
9214                }
9215            }
9216            _ => {
9217                return Err(LinkError::UnsafePath {
9218                    path: child_display,
9219                });
9220            }
9221        }
9222    }
9223    destination.sync_all()?;
9224    Ok(())
9225}
9226
9227#[cfg(target_os = "linux")]
9228fn install_stage_at(
9229    parent: std::os::fd::RawFd,
9230    stage: &std::ffi::CStr,
9231    dest: &std::ffi::CStr,
9232    dest_exists: bool,
9233) -> LinkResult<()> {
9234    let flags = if dest_exists {
9235        libc::RENAME_EXCHANGE
9236    } else {
9237        libc::RENAME_NOREPLACE
9238    };
9239    // `libc::renameat2` is not exported for the musl targets we ship. Invoke
9240    // the kernel ABI directly, as `fsx::renameat_noreplace` does, so the same
9241    // atomic exchange/no-replace boundary compiles for glibc and musl.
9242    let result = unsafe {
9243        libc::syscall(
9244            libc::SYS_renameat2,
9245            parent,
9246            stage.as_ptr(),
9247            parent,
9248            dest.as_ptr(),
9249            flags,
9250        )
9251    };
9252    if result == 0 {
9253        Ok(())
9254    } else {
9255        Err(std::io::Error::last_os_error().into())
9256    }
9257}
9258
9259#[cfg(target_os = "macos")]
9260fn install_stage_at(
9261    parent: std::os::fd::RawFd,
9262    stage: &std::ffi::CStr,
9263    dest: &std::ffi::CStr,
9264    dest_exists: bool,
9265) -> LinkResult<()> {
9266    let flags = if dest_exists {
9267        libc::RENAME_SWAP
9268    } else {
9269        libc::RENAME_EXCL
9270    };
9271    let result =
9272        unsafe { libc::renameatx_np(parent, stage.as_ptr(), parent, dest.as_ptr(), flags) };
9273    if result == 0 {
9274        Ok(())
9275    } else {
9276        Err(std::io::Error::last_os_error().into())
9277    }
9278}
9279
9280#[cfg(unix)]
9281fn write_pull_entries_beneath_dir(
9282    root: &std::fs::File,
9283    entries: &[(String, Vec<u8>)],
9284) -> LinkResult<()> {
9285    use std::os::fd::{AsRawFd as _, FromRawFd as _};
9286
9287    for (path, content) in entries {
9288        let components: Vec<&str> = path.split('/').collect();
9289        let (leaf, parents) = components
9290            .split_last()
9291            .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9292        let mut directory = root.try_clone()?;
9293        for component in parents {
9294            let name = c_name(component.as_bytes(), path)?;
9295            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9296            if made != 0 {
9297                let error = std::io::Error::last_os_error();
9298                if error.raw_os_error() != Some(libc::EEXIST) {
9299                    return Err(error.into());
9300                }
9301            }
9302            directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9303        }
9304
9305        let leaf_name = c_name(leaf.as_bytes(), path)?;
9306        let mut existing: libc::stat = unsafe { std::mem::zeroed() };
9307        let inspected = unsafe {
9308            libc::fstatat(
9309                directory.as_raw_fd(),
9310                leaf_name.as_ptr(),
9311                &mut existing,
9312                libc::AT_SYMLINK_NOFOLLOW,
9313            )
9314        };
9315        if inspected == 0 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK {
9316            return Err(LinkError::UnsafePath { path: path.clone() });
9317        }
9318
9319        let nonce = std::time::SystemTime::now()
9320            .duration_since(std::time::UNIX_EPOCH)
9321            .unwrap_or_default()
9322            .as_nanos();
9323        let temp_name = format!(
9324            ".dbmd-pull-{}-{nonce}-{}",
9325            std::process::id(),
9326            content_sha256(format!("{path}\0{}", content.len()).as_bytes())
9327        );
9328        let temp = c_name(temp_name.as_bytes(), path)?;
9329        let fd = unsafe {
9330            libc::openat(
9331                directory.as_raw_fd(),
9332                temp.as_ptr(),
9333                libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9334                0o600,
9335            )
9336        };
9337        if fd < 0 {
9338            return Err(std::io::Error::last_os_error().into());
9339        }
9340        let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
9341        if let Err(error) = file.write_all(content).and_then(|_| file.sync_all()) {
9342            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9343            return Err(error.into());
9344        }
9345        drop(file);
9346        let renamed = unsafe {
9347            libc::renameat(
9348                directory.as_raw_fd(),
9349                temp.as_ptr(),
9350                directory.as_raw_fd(),
9351                leaf_name.as_ptr(),
9352            )
9353        };
9354        if renamed != 0 {
9355            let error = std::io::Error::last_os_error();
9356            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9357            return Err(error.into());
9358        }
9359        directory.sync_all()?;
9360    }
9361    root.sync_all()?;
9362    Ok(())
9363}
9364
9365#[cfg(unix)]
9366fn write_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
9367    use std::os::fd::{AsRawFd as _, FromRawFd as _};
9368
9369    let path = &entry.path;
9370    let components: Vec<&str> = path.split('/').collect();
9371    let (leaf, parents) = components
9372        .split_last()
9373        .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9374    let mut directory = root.try_clone()?;
9375    for component in parents {
9376        let name = c_name(component.as_bytes(), path)?;
9377        let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9378        if made != 0 {
9379            let error = std::io::Error::last_os_error();
9380            if error.raw_os_error() != Some(libc::EEXIST) {
9381                return Err(error.into());
9382            }
9383        }
9384        directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9385    }
9386    let leaf_name = c_name(leaf.as_bytes(), path)?;
9387    let mut existing: libc::stat = unsafe { std::mem::zeroed() };
9388    if unsafe {
9389        libc::fstatat(
9390            directory.as_raw_fd(),
9391            leaf_name.as_ptr(),
9392            &mut existing,
9393            libc::AT_SYMLINK_NOFOLLOW,
9394        )
9395    } == 0
9396        && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK
9397    {
9398        return Err(LinkError::UnsafePath { path: path.clone() });
9399    }
9400    let nonce = SystemTime::now()
9401        .duration_since(UNIX_EPOCH)
9402        .unwrap_or_default()
9403        .as_nanos();
9404    let temp_name = format!(
9405        ".dbmd-pull-{}-{nonce}-{}",
9406        std::process::id(),
9407        content_sha256(path.as_bytes())
9408    );
9409    let temp = c_name(temp_name.as_bytes(), path)?;
9410    let fd = unsafe {
9411        libc::openat(
9412            directory.as_raw_fd(),
9413            temp.as_ptr(),
9414            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9415            0o600,
9416        )
9417    };
9418    if fd < 0 {
9419        return Err(std::io::Error::last_os_error().into());
9420    }
9421    let mut input = crate::fsx::open_regular_nofollow(&entry.source)?;
9422    let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
9423    let mut digest = Sha256::new();
9424    let mut total = 0_u64;
9425    let mut buffer = [0_u8; 64 * 1024];
9426    let copied = (|| -> std::io::Result<()> {
9427        loop {
9428            let read = input.read(&mut buffer)?;
9429            if read == 0 {
9430                break;
9431            }
9432            total = total.saturating_add(read as u64);
9433            if total > entry.bytes {
9434                return Err(std::io::Error::new(
9435                    std::io::ErrorKind::InvalidData,
9436                    "staged sync source grew beyond its verified length",
9437                ));
9438            }
9439            digest.update(&buffer[..read]);
9440            output.write_all(&buffer[..read])?;
9441        }
9442        Ok(())
9443    })();
9444    if let Err(error) = copied {
9445        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9446        return Err(error.into());
9447    }
9448    drop(output);
9449    if total != entry.bytes || format!("{:x}", digest.finalize()) != entry.sha256 {
9450        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9451        return Err(invalid_feed(
9452            "private staged sync source failed final integrity verification",
9453        ));
9454    }
9455    if unsafe {
9456        libc::renameat(
9457            directory.as_raw_fd(),
9458            temp.as_ptr(),
9459            directory.as_raw_fd(),
9460            leaf_name.as_ptr(),
9461        )
9462    } != 0
9463    {
9464        let error = std::io::Error::last_os_error();
9465        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9466        return Err(error.into());
9467    }
9468    Ok(())
9469}
9470
9471#[cfg(unix)]
9472fn sync_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
9473    use std::os::fd::{AsRawFd as _, FromRawFd as _};
9474
9475    let path = &entry.path;
9476    let components: Vec<&str> = path.split('/').collect();
9477    let (leaf, parents) = components
9478        .split_last()
9479        .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9480    let mut directory = root.try_clone()?;
9481    for component in parents {
9482        directory = open_dir_at(
9483            directory.as_raw_fd(),
9484            &c_name(component.as_bytes(), path)?,
9485            path,
9486        )?;
9487    }
9488    let leaf = c_name(leaf.as_bytes(), path)?;
9489    let fd = unsafe {
9490        libc::openat(
9491            directory.as_raw_fd(),
9492            leaf.as_ptr(),
9493            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9494        )
9495    };
9496    if fd < 0 {
9497        return Err(std::io::Error::last_os_error().into());
9498    }
9499    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
9500    if file.metadata()?.len() != entry.bytes || content_sha256_reader(&mut file)? != entry.sha256 {
9501        return Err(invalid_feed(
9502            "private pull stage changed before its durability barrier",
9503        ));
9504    }
9505    file.sync_all()?;
9506    Ok(())
9507}
9508
9509#[cfg(unix)]
9510fn run_pull_source_workers(
9511    root: &std::fs::File,
9512    entries: &[V2StagedFile],
9513    operation: fn(&std::fs::File, &V2StagedFile) -> LinkResult<()>,
9514) -> LinkResult<()> {
9515    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
9516
9517    let next = AtomicUsize::new(0);
9518    let failed = AtomicBool::new(false);
9519    let worker_count = entries.len().min(V2_PULL_INSTALL_WORKERS);
9520    let mut first_error = None;
9521    std::thread::scope(|scope| {
9522        let (sender, receiver) = std::sync::mpsc::channel();
9523        for _ in 0..worker_count {
9524            let sender = sender.clone();
9525            let next = &next;
9526            let failed = &failed;
9527            scope.spawn(move || {
9528                while !failed.load(Ordering::Acquire) {
9529                    let index = next.fetch_add(1, Ordering::Relaxed);
9530                    let Some(entry) = entries.get(index) else {
9531                        break;
9532                    };
9533                    let result = operation(root, entry);
9534                    if result.is_err() {
9535                        failed.store(true, Ordering::Release);
9536                    }
9537                    if sender.send(result).is_err() {
9538                        break;
9539                    }
9540                }
9541            });
9542        }
9543        drop(sender);
9544        for result in receiver {
9545            if let Err(error) = result {
9546                if first_error.is_none() {
9547                    first_error = Some(error);
9548                }
9549            }
9550        }
9551    });
9552    if let Some(error) = first_error {
9553        return Err(error);
9554    }
9555    if next.load(Ordering::Relaxed) < entries.len() {
9556        return Err(invalid_feed(
9557            "a bounded pull worker stopped before reporting every file",
9558        ));
9559    }
9560    Ok(())
9561}
9562
9563#[cfg(unix)]
9564fn sync_pull_directory_tree(root: &std::fs::File, display: &str) -> LinkResult<()> {
9565    use std::os::fd::AsRawFd as _;
9566
9567    for name in directory_entry_names(root)? {
9568        if entry_is_dir_at(root.as_raw_fd(), &name)? == Some(true) {
9569            let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
9570            let child = open_dir_at(root.as_raw_fd(), &name, &child_display)?;
9571            sync_pull_directory_tree(&child, &child_display)?;
9572        }
9573    }
9574    root.sync_all()?;
9575    Ok(())
9576}
9577
9578#[cfg(unix)]
9579fn write_pull_sources_beneath_dir(
9580    root: &std::fs::File,
9581    entries: &[V2StagedFile],
9582) -> LinkResult<()> {
9583    // The stage is still private and unreachable as the checkout here. Write
9584    // and verify every leaf first so the filesystem can coalesce dirty data,
9585    // then re-open/re-hash/fsync leaves through the held root capability. Only
9586    // after every file barrier succeeds do we flush each directory once. This
9587    // preserves the same crash durability as file-fsync + directory-fsync per
9588    // path without serializing 50,000 redundant directory barriers.
9589    run_pull_source_workers(root, entries, write_pull_source_beneath_dir)?;
9590    run_pull_source_workers(root, entries, sync_pull_source_beneath_dir)?;
9591    sync_pull_directory_tree(root, "v2 pull stage")
9592}
9593
9594#[cfg(unix)]
9595fn remove_pull_paths_beneath_dir(root: &std::fs::File, paths: &[String]) -> LinkResult<()> {
9596    use std::os::fd::AsRawFd as _;
9597    for path in paths {
9598        if !safe_store_rel_path(path) {
9599            return Err(LinkError::UnsafePath { path: path.clone() });
9600        }
9601        let components = path.split('/').collect::<Vec<_>>();
9602        let Some((leaf, parents)) = components.split_last() else {
9603            return Err(LinkError::UnsafePath { path: path.clone() });
9604        };
9605        let mut directory = root.try_clone()?;
9606        let mut missing = false;
9607        for component in parents {
9608            let name = c_name(component.as_bytes(), path)?;
9609            match entry_is_dir_at(directory.as_raw_fd(), &name)? {
9610                None => {
9611                    missing = true;
9612                    break;
9613                }
9614                Some(false) => return Err(LinkError::UnsafePath { path: path.clone() }),
9615                Some(true) => {
9616                    directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9617                }
9618            }
9619        }
9620        if missing {
9621            continue;
9622        }
9623        let leaf = c_name(leaf.as_bytes(), path)?;
9624        match entry_is_dir_at(directory.as_raw_fd(), &leaf)? {
9625            None => {}
9626            Some(true) => return Err(LinkError::UnsafePath { path: path.clone() }),
9627            Some(false) => {
9628                if unsafe { libc::unlinkat(directory.as_raw_fd(), leaf.as_ptr(), 0) } != 0 {
9629                    return Err(std::io::Error::last_os_error().into());
9630                }
9631                directory.sync_all()?;
9632            }
9633        }
9634    }
9635    Ok(())
9636}
9637
9638#[cfg(unix)]
9639fn install_pulled_delta(
9640    dest: &Path,
9641    entries: &[(String, Vec<u8>)],
9642    deleted: &[String],
9643    rebuild_indexes: bool,
9644) -> LinkResult<()> {
9645    use ring::rand::SecureRandom as _;
9646    use std::os::fd::AsRawFd as _;
9647    use std::os::unix::ffi::OsStrExt as _;
9648
9649    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
9650    let name = dest
9651        .file_name()
9652        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
9653        .ok_or_else(|| LinkError::UnsafePath {
9654            path: dest.display().to_string(),
9655        })?;
9656    let parent_dir = open_or_create_dir_nofollow(parent)?;
9657    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
9658    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
9659        None => false,
9660        Some(true) => true,
9661        Some(false) => {
9662            return Err(LinkError::UnsafePath {
9663                path: dest.display().to_string(),
9664            });
9665        }
9666    };
9667
9668    let mut nonce = [0_u8; 16];
9669    ring::rand::SystemRandom::new()
9670        .fill(&mut nonce)
9671        .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
9672    let stage_label = format!(
9673        ".{}.dbmd-pull-stage-{}",
9674        name.to_string_lossy(),
9675        URL_SAFE_NO_PAD.encode(nonce)
9676    );
9677    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
9678    let stage_dir = create_dir_exclusive_at(
9679        parent_dir.as_raw_fd(),
9680        &stage_name,
9681        &dest.display().to_string(),
9682    )?;
9683
9684    let prepared = (|| -> LinkResult<()> {
9685        if dest_exists {
9686            let live = open_dir_at(
9687                parent_dir.as_raw_fd(),
9688                &dest_name,
9689                &dest.display().to_string(),
9690            )?;
9691            clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
9692        }
9693        remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
9694        write_pull_entries_beneath_dir(&stage_dir, entries)?;
9695        if rebuild_indexes {
9696            let stage_store =
9697                Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
9698                    .map_err(|error| LinkError::InvalidPack {
9699                        message: format!("v2 staging tree is not a valid db.md store: {error}"),
9700                    })?;
9701            crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
9702                LinkError::InvalidPack {
9703                    message: format!("could not materialize v2 local catalogs: {error}"),
9704                }
9705            })?;
9706        }
9707        stage_dir.sync_all()?;
9708        Ok(())
9709    })();
9710    if let Err(error) = prepared {
9711        let _ = remove_tree_at(
9712            parent_dir.as_raw_fd(),
9713            &stage_name,
9714            &dest.display().to_string(),
9715        );
9716        return Err(error);
9717    }
9718
9719    if let Err(error) =
9720        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
9721    {
9722        let _ = remove_tree_at(
9723            parent_dir.as_raw_fd(),
9724            &stage_name,
9725            &dest.display().to_string(),
9726        );
9727        return Err(error);
9728    }
9729    parent_dir.sync_all()?;
9730    if dest_exists {
9731        // Commit already happened atomically. Cleanup is best-effort so a
9732        // failure cannot be reported as a failed pull after the live tree
9733        // changed; a crash may leave only this private old-tree sibling.
9734        let _ = remove_tree_at(
9735            parent_dir.as_raw_fd(),
9736            &stage_name,
9737            &dest.display().to_string(),
9738        );
9739        let _ = parent_dir.sync_all();
9740    }
9741    Ok(())
9742}
9743
9744#[cfg(unix)]
9745fn install_pulled_delta_sources(
9746    dest: &Path,
9747    entries: &[V2StagedFile],
9748    deleted: &[String],
9749    rebuild_indexes: bool,
9750    _previous: Option<&V2SyncBaseline>,
9751    _next: &V2VerifiedHead,
9752) -> LinkResult<()> {
9753    use ring::rand::SecureRandom as _;
9754    use std::os::fd::AsRawFd as _;
9755    use std::os::unix::ffi::OsStrExt as _;
9756
9757    // An established checkout is updated only at the changed coordinates
9758    // through the durable pull journal shared with Windows. Initial clones
9759    // still use a private complete stage and one atomic directory install.
9760    if let Ok(store) = Store::open_strict(dest) {
9761        return install_established_v2_delta(
9762            store,
9763            entries,
9764            deleted,
9765            rebuild_indexes,
9766            _previous,
9767            _next,
9768        );
9769    }
9770
9771    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
9772    let name = dest
9773        .file_name()
9774        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
9775        .ok_or_else(|| LinkError::UnsafePath {
9776            path: dest.display().to_string(),
9777        })?;
9778    let parent_dir = open_or_create_dir_nofollow(parent)?;
9779    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
9780    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
9781        None => false,
9782        Some(true) => true,
9783        Some(false) => {
9784            return Err(LinkError::UnsafePath {
9785                path: dest.display().to_string(),
9786            })
9787        }
9788    };
9789    let mut nonce = [0_u8; 16];
9790    ring::rand::SystemRandom::new()
9791        .fill(&mut nonce)
9792        .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
9793    let stage_label = format!(
9794        ".{}.dbmd-pull-stage-{}",
9795        name.to_string_lossy(),
9796        URL_SAFE_NO_PAD.encode(nonce)
9797    );
9798    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
9799    let stage_dir = create_dir_exclusive_at(
9800        parent_dir.as_raw_fd(),
9801        &stage_name,
9802        &dest.display().to_string(),
9803    )?;
9804    let prepared = (|| -> LinkResult<()> {
9805        if dest_exists {
9806            let live = open_dir_at(
9807                parent_dir.as_raw_fd(),
9808                &dest_name,
9809                &dest.display().to_string(),
9810            )?;
9811            clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
9812        }
9813        remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
9814        write_pull_sources_beneath_dir(&stage_dir, entries)?;
9815        if rebuild_indexes {
9816            let stage_store =
9817                Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
9818                    .map_err(|error| LinkError::InvalidPack {
9819                        message: format!("v2 staging tree is not a valid db.md store: {error}"),
9820                    })?;
9821            crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
9822                LinkError::InvalidPack {
9823                    message: format!("could not materialize v2 local catalogs: {error}"),
9824                }
9825            })?;
9826        }
9827        stage_dir.sync_all()?;
9828        Ok(())
9829    })();
9830    if let Err(error) = prepared {
9831        let _ = remove_tree_at(
9832            parent_dir.as_raw_fd(),
9833            &stage_name,
9834            &dest.display().to_string(),
9835        );
9836        return Err(error);
9837    }
9838    if let Err(error) =
9839        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
9840    {
9841        let _ = remove_tree_at(
9842            parent_dir.as_raw_fd(),
9843            &stage_name,
9844            &dest.display().to_string(),
9845        );
9846        return Err(error);
9847    }
9848    parent_dir.sync_all()?;
9849    if dest_exists {
9850        let _ = remove_tree_at(
9851            parent_dir.as_raw_fd(),
9852            &stage_name,
9853            &dest.display().to_string(),
9854        );
9855        let _ = parent_dir.sync_all();
9856    }
9857    Ok(())
9858}
9859
9860#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
9861struct V2PullCoordinate {
9862    head_seq: Option<u64>,
9863    commit_hash: Option<String>,
9864    view_kind: Option<String>,
9865    view_revision: Option<String>,
9866}
9867
9868#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
9869struct V2PullFileCoordinate {
9870    sha256: String,
9871    bytes: u64,
9872}
9873
9874#[derive(Debug, Clone, Deserialize, Serialize)]
9875struct V2PullJournalEntry {
9876    path: String,
9877    old: Option<V2PullFileCoordinate>,
9878    new: Option<V2PullFileCoordinate>,
9879    backup: Option<String>,
9880}
9881
9882#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
9883#[serde(rename_all = "snake_case")]
9884enum V2PullPhase {
9885    Preparing,
9886    Ready,
9887}
9888
9889#[derive(Debug, Clone, Deserialize, Serialize)]
9890struct V2PullJournal {
9891    v: u8,
9892    phase: V2PullPhase,
9893    brain: String,
9894    previous: V2PullCoordinate,
9895    next: V2PullCoordinate,
9896    backup_dir: String,
9897    entries: Vec<V2PullJournalEntry>,
9898}
9899
9900const V2_PULL_JOURNAL: &str = ".dbmd/pull-journal.json";
9901
9902fn v2_pull_baseline_coordinate(baseline: Option<&V2SyncBaseline>) -> V2PullCoordinate {
9903    V2PullCoordinate {
9904        head_seq: baseline.and_then(|value| value.head_seq),
9905        commit_hash: baseline.and_then(|value| value.commit_hash.clone()),
9906        view_kind: baseline.and_then(|value| value.view_kind.clone()),
9907        view_revision: baseline.and_then(|value| value.view_revision.clone()),
9908    }
9909}
9910
9911fn v2_pull_head_coordinate(head: &V2VerifiedHead) -> V2PullCoordinate {
9912    V2PullCoordinate {
9913        head_seq: head.pointer.as_ref().map(|value| value.seq),
9914        commit_hash: head.pointer.as_ref().map(|value| value.commit_hash.clone()),
9915        view_kind: Some(head.view_kind.clone()),
9916        view_revision: Some(head.view_revision.clone()),
9917    }
9918}
9919
9920fn v2_pull_file_coordinate(
9921    store: &Store,
9922    path: &str,
9923    limit: u64,
9924) -> LinkResult<Option<V2PullFileCoordinate>> {
9925    let file = match store.open_regular(Path::new(path)) {
9926        Ok(file) => file,
9927        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
9928        Err(error) => return Err(error.into()),
9929    };
9930    let bytes = file.metadata()?.len();
9931    if bytes > limit || bytes > MAX_STORE_BYTES {
9932        return Err(invalid_feed(
9933            "pull transaction file exceeds its declared bound",
9934        ));
9935    }
9936    Ok(Some(V2PullFileCoordinate {
9937        sha256: content_sha256_reader(file)?,
9938        bytes,
9939    }))
9940}
9941
9942fn v2_pull_journal_bytes(journal: &V2PullJournal) -> LinkResult<Vec<u8>> {
9943    let mut bytes = serde_json::to_vec_pretty(journal)
9944        .map_err(|_| invalid_feed("could not serialize v2 pull journal"))?;
9945    bytes.push(b'\n');
9946    Ok(bytes)
9947}
9948
9949fn validate_v2_pull_journal(journal: &V2PullJournal) -> LinkResult<()> {
9950    let backup_prefix = ".dbmd/pull-backup-";
9951    let suffix = journal
9952        .backup_dir
9953        .strip_prefix(backup_prefix)
9954        .ok_or_else(|| invalid_feed("v2 pull journal backup address is invalid"))?;
9955    let mut paths = std::collections::BTreeSet::new();
9956    if journal.v != 1
9957        || !crate::ulid::is_ulid(&journal.brain)
9958        || !crate::ulid::is_ulid(suffix)
9959        || journal.entries.is_empty()
9960        || journal.entries.len() > MAX_PUSH_FILES + 4
9961        || journal.previous == journal.next
9962    {
9963        return Err(invalid_feed("v2 pull journal failed validation"));
9964    }
9965    for (index, entry) in journal.entries.iter().enumerate() {
9966        if !safe_store_rel_path(&entry.path)
9967            || entry.path == V2_PULL_JOURNAL
9968            || entry.path.starts_with(backup_prefix)
9969            || !paths.insert(entry.path.clone())
9970            || (entry.old.is_none() && entry.new.is_none())
9971            || entry
9972                .old
9973                .iter()
9974                .chain(entry.new.iter())
9975                .any(|value| !is_sha256(&value.sha256) || value.bytes > MAX_STORE_BYTES)
9976            || entry.backup.as_deref()
9977                != entry
9978                    .old
9979                    .as_ref()
9980                    .map(|_| format!("{index:08x}"))
9981                    .as_deref()
9982        {
9983            return Err(invalid_feed("v2 pull journal entry failed validation"));
9984        }
9985    }
9986    Ok(())
9987}
9988
9989fn load_v2_pull_journal(store: &Store) -> LinkResult<Option<V2PullJournal>> {
9990    #[cfg(unix)]
9991    {
9992        use std::os::unix::fs::PermissionsExt as _;
9993        match store.regular_metadata(Path::new(V2_PULL_JOURNAL)) {
9994            Ok(metadata) if metadata.permissions().mode() & 0o077 != 0 => {
9995                return Err(invalid_feed(
9996                    "v2 pull journal is accessible to group/other; set mode 0600",
9997                ));
9998            }
9999            Ok(_) => {}
10000            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10001            Err(error) => return Err(error.into()),
10002        }
10003    }
10004    let bytes = match store.read_bounded(Path::new(V2_PULL_JOURNAL), 64 * 1024 * 1024) {
10005        Ok(bytes) => bytes,
10006        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10007        Err(error) => return Err(error.into()),
10008    };
10009    let journal: V2PullJournal =
10010        serde_json::from_slice(&bytes).map_err(|_| invalid_feed("v2 pull journal is corrupt"))?;
10011    validate_v2_pull_journal(&journal)?;
10012    Ok(Some(journal))
10013}
10014
10015fn cleanup_v2_pull_journal(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
10016    // The journal is the only recovery authority. Remove it first only after
10017    // rollback or baseline commit is durable; an orphan private backup is safe
10018    // and can be pruned, while a journal without its backups is not recoverable.
10019    match store.remove_file(Path::new(V2_PULL_JOURNAL)) {
10020        Ok(()) => {}
10021        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
10022        Err(error) => return Err(error.into()),
10023    }
10024    match store.remove_private_tree(Path::new(&journal.backup_dir)) {
10025        Ok(()) => Ok(()),
10026        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
10027        Err(error) => Err(error.into()),
10028    }
10029}
10030
10031fn prune_orphan_v2_pull_backups(store: &Store) -> LinkResult<()> {
10032    let names = match store.directory_names(Path::new(".dbmd")) {
10033        Ok(names) => names,
10034        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
10035        Err(error) => return Err(error.into()),
10036    };
10037    for name in names {
10038        let Some(name) = name.to_str() else {
10039            continue;
10040        };
10041        let Some(suffix) = name.strip_prefix("pull-backup-") else {
10042            continue;
10043        };
10044        if crate::ulid::is_ulid(suffix) {
10045            store.remove_private_tree(&Path::new(".dbmd").join(name))?;
10046        }
10047    }
10048    Ok(())
10049}
10050
10051fn rollback_v2_pull(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
10052    // Validate every live coordinate and every backup before the first restore.
10053    for entry in &journal.entries {
10054        let limit = entry
10055            .old
10056            .as_ref()
10057            .into_iter()
10058            .chain(entry.new.iter())
10059            .map(|value| value.bytes)
10060            .max()
10061            .unwrap_or(0);
10062        let current = v2_pull_file_coordinate(store, &entry.path, limit)?;
10063        if current != entry.old && current != entry.new {
10064            return Err(LinkError::InvalidPack {
10065                message: format!(
10066                    "cannot recover interrupted pull because `{}` changed afterward",
10067                    entry.path
10068                ),
10069            });
10070        }
10071        if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
10072            let path = Path::new(&journal.backup_dir).join(backup);
10073            let file = store.open_regular(&path)?;
10074            if file.metadata()?.len() != old.bytes || content_sha256_reader(file)? != old.sha256 {
10075                return Err(invalid_feed("v2 pull recovery backup failed verification"));
10076            }
10077        }
10078    }
10079    for entry in journal.entries.iter().rev() {
10080        match (&entry.old, &entry.backup) {
10081            (Some(old), Some(backup)) => {
10082                let bytes =
10083                    store.read_bounded(&Path::new(&journal.backup_dir).join(backup), old.bytes)?;
10084                store.write_atomic(Path::new(&entry.path), &bytes)?;
10085            }
10086            (None, None) if store.regular_file_exists(Path::new(&entry.path))? => {
10087                store.remove_file(Path::new(&entry.path))?;
10088            }
10089            (None, None) => {}
10090            _ => return Err(invalid_feed("v2 pull recovery entry is inconsistent")),
10091        }
10092    }
10093    crate::index::Index::rebuild_all(store).map_err(|error| LinkError::InvalidPack {
10094        message: format!("could not rebuild catalogs after pull recovery: {error}"),
10095    })?;
10096    cleanup_v2_pull_journal(store, journal)
10097}
10098
10099fn recover_v2_pull(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<()> {
10100    let Ok(store) = Store::open_strict(dest) else {
10101        return Ok(());
10102    };
10103    if let Some(journal) = load_v2_pull_journal(&store)? {
10104        if journal.brain != brain {
10105            return Err(invalid_feed("v2 pull journal belongs to another brain"));
10106        }
10107        if journal.phase == V2PullPhase::Preparing {
10108            cleanup_v2_pull_journal(&store, &journal)?;
10109        } else {
10110            let baseline = load_v2_baseline(cfg, brain, dest)?;
10111            let current = v2_pull_baseline_coordinate(baseline.as_ref());
10112            if current == journal.next {
10113                cleanup_v2_pull_journal(&store, &journal)?;
10114            } else {
10115                if current != journal.previous {
10116                    return Err(invalid_feed(
10117                        "cannot recover interrupted pull because its baseline changed afterward",
10118                    ));
10119                }
10120                rollback_v2_pull(&store, &journal)?;
10121            }
10122        }
10123    }
10124    // Cleanup removes the journal first after the state is durably old or new.
10125    // A hard kill in the tiny interval before deleting its backup directory can
10126    // therefore leave only an inert, private orphan. The per-brain operation
10127    // lock is held by the caller, so no live pull can own one here.
10128    prune_orphan_v2_pull_backups(&store)
10129}
10130
10131fn complete_v2_pull(dest: &Path) -> LinkResult<()> {
10132    let store = Store::open_strict(dest).map_err(|error| LinkError::InvalidPack {
10133        message: format!("installed v2 checkout is not a valid db.md store: {error}"),
10134    })?;
10135    if let Some(journal) = load_v2_pull_journal(&store)? {
10136        cleanup_v2_pull_journal(&store, &journal)?;
10137    }
10138    Ok(())
10139}
10140
10141#[cfg(windows)]
10142fn install_windows_initial_sources(
10143    dest: &Path,
10144    entries: &[V2StagedFile],
10145    rebuild_indexes: bool,
10146) -> LinkResult<()> {
10147    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10148    let name = dest.file_name().ok_or_else(|| LinkError::UnsafePath {
10149        path: dest.display().to_string(),
10150    })?;
10151    let parent_capability = crate::fsx::open_or_create_directory_nofollow(parent)?;
10152    if crate::fsx::directory_exists_beneath(&parent_capability, Path::new(name))? {
10153        return Err(LinkError::UnsafePath {
10154            path: dest.display().to_string(),
10155        });
10156    }
10157    let stage_name = format!(
10158        ".{}.dbmd-pull-stage-{}",
10159        name.to_string_lossy(),
10160        crate::ulid::mint()
10161    );
10162    let stage_path = parent.join(&stage_name);
10163    let stage_capability =
10164        crate::fsx::open_directory_beneath(&parent_capability, Path::new(&stage_name), true)?;
10165    let stage = Store::from_root_and_config(&stage_path, crate::Config::default())?;
10166    let prepared = (|| -> LinkResult<()> {
10167        for entry in entries {
10168            let bytes = crate::fsx::read_bounded_nofollow(&entry.source, entry.bytes)?;
10169            if bytes.len() as u64 != entry.bytes || content_sha256(&bytes) != entry.sha256 {
10170                return Err(invalid_feed(
10171                    "private staged sync source failed final integrity verification",
10172                ));
10173            }
10174            stage.write_atomic(Path::new(&entry.path), &bytes)?;
10175        }
10176        let strict = Store::from_held_root_strict(&stage_path, stage_capability.try_clone()?)
10177            .map_err(|error| LinkError::InvalidPack {
10178                message: format!("v2 staging tree is not a valid db.md store: {error}"),
10179            })?;
10180        if rebuild_indexes {
10181            crate::index::Index::rebuild_all(&strict).map_err(|error| LinkError::InvalidPack {
10182                message: format!("could not materialize v2 local catalogs: {error}"),
10183            })?;
10184        }
10185        Ok(())
10186    })();
10187    if let Err(error) = prepared {
10188        let _ = crate::fsx::remove_tree_beneath(&parent_capability, Path::new(&stage_name));
10189        return Err(error);
10190    }
10191    crate::fsx::rename_directory_beneath(
10192        &parent_capability,
10193        Path::new(&stage_name),
10194        Path::new(name),
10195    )?;
10196    Ok(())
10197}
10198
10199fn install_established_v2_delta(
10200    store: Store,
10201    entries: &[V2StagedFile],
10202    deleted: &[String],
10203    rebuild_indexes: bool,
10204    previous: Option<&V2SyncBaseline>,
10205    next: &V2VerifiedHead,
10206) -> LinkResult<()> {
10207    if load_v2_pull_journal(&store)?.is_some() {
10208        return Err(invalid_feed(
10209            "an interrupted pull must be recovered before installing",
10210        ));
10211    }
10212    let mut sources = std::collections::BTreeMap::new();
10213    for entry in entries {
10214        if sources.insert(entry.path.clone(), entry).is_some() || deleted.contains(&entry.path) {
10215            return Err(invalid_feed("pull mutation repeats a path"));
10216        }
10217    }
10218    let mut paths = sources.keys().cloned().collect::<Vec<_>>();
10219    paths.extend(deleted.iter().cloned());
10220    paths.sort();
10221    paths.dedup();
10222    if paths.is_empty() {
10223        return Ok(());
10224    }
10225    let backup_dir = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
10226    let mut journal = V2PullJournal {
10227        v: 1,
10228        phase: V2PullPhase::Preparing,
10229        brain: next.brain_id.clone(),
10230        previous: v2_pull_baseline_coordinate(previous),
10231        next: v2_pull_head_coordinate(next),
10232        backup_dir: backup_dir.clone(),
10233        entries: Vec::with_capacity(paths.len()),
10234    };
10235    for path in &paths {
10236        let old = v2_pull_file_coordinate(&store, path, MAX_STORE_BYTES)?;
10237        let new = sources.get(path).map(|entry| V2PullFileCoordinate {
10238            sha256: entry.sha256.clone(),
10239            bytes: entry.bytes,
10240        });
10241        if old == new {
10242            continue;
10243        }
10244        let index = journal.entries.len();
10245        journal.entries.push(V2PullJournalEntry {
10246            path: path.clone(),
10247            backup: old.as_ref().map(|_| format!("{index:08x}")),
10248            old,
10249            new,
10250        });
10251    }
10252    if journal.entries.is_empty() {
10253        return Ok(());
10254    }
10255    let backup_bytes = journal.entries.iter().try_fold(0_u64, |total, entry| {
10256        entry
10257            .old
10258            .as_ref()
10259            .map_or(Some(total), |old| total.checked_add(old.bytes))
10260    });
10261    if backup_bytes.is_none_or(|bytes| bytes > MAX_STORE_BYTES) {
10262        return Err(LinkError::InvalidPack {
10263            message: "pull recovery preimages exceed the 512 MB transaction limit".to_string(),
10264        });
10265    }
10266    validate_v2_pull_journal(&journal)?;
10267    store.write_private_atomic_new(
10268        Path::new(V2_PULL_JOURNAL),
10269        &v2_pull_journal_bytes(&journal)?,
10270    )?;
10271    let prepared = (|| -> LinkResult<()> {
10272        store.create_private_dir_all(Path::new(&backup_dir))?;
10273        for entry in &journal.entries {
10274            if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
10275                let bytes = store.read_bounded(Path::new(&entry.path), old.bytes)?;
10276                if content_sha256(&bytes) != old.sha256 {
10277                    return Err(invalid_feed("live pull source changed during backup"));
10278                }
10279                store.write_private_atomic_new(&Path::new(&backup_dir).join(backup), &bytes)?;
10280            }
10281        }
10282        journal.phase = V2PullPhase::Ready;
10283        store.write_private_atomic(
10284            Path::new(V2_PULL_JOURNAL),
10285            &v2_pull_journal_bytes(&journal)?,
10286        )?;
10287        Ok(())
10288    })();
10289    if let Err(error) = prepared {
10290        let cleanup = cleanup_v2_pull_journal(&store, &journal);
10291        return match cleanup {
10292            Ok(()) => Err(error),
10293            Err(cleanup) => Err(LinkError::InvalidPack {
10294                message: format!("{error}; recovery metadata cleanup also failed: {cleanup}"),
10295            }),
10296        };
10297    }
10298    let installed = (|| -> LinkResult<()> {
10299        for entry in &journal.entries {
10300            if v2_pull_file_coordinate(&store, &entry.path, MAX_STORE_BYTES)? != entry.old {
10301                return Err(LinkError::InvalidPack {
10302                    message: format!("local path `{}` changed during pull", entry.path),
10303                });
10304            }
10305            if let Some(source) = sources.get(&entry.path) {
10306                let bytes = crate::fsx::read_bounded_nofollow(&source.source, source.bytes)?;
10307                if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != source.sha256 {
10308                    return Err(invalid_feed(
10309                        "private staged sync source failed final integrity verification",
10310                    ));
10311                }
10312                store.write_atomic(Path::new(&entry.path), &bytes)?;
10313            } else if entry.old.is_some() {
10314                store.remove_file(Path::new(&entry.path))?;
10315            }
10316        }
10317        if rebuild_indexes {
10318            crate::index::Index::rebuild_all(&store).map_err(|error| LinkError::InvalidPack {
10319                message: format!("could not materialize v2 local catalogs: {error}"),
10320            })?;
10321        }
10322        Ok(())
10323    })();
10324    if let Err(error) = installed {
10325        return match rollback_v2_pull(&store, &journal) {
10326            Ok(()) => Err(error),
10327            Err(rollback) => Err(LinkError::InvalidPack {
10328                message: format!("{error}; durable pull rollback also failed: {rollback}"),
10329            }),
10330        };
10331    }
10332    Ok(())
10333}
10334
10335#[cfg(windows)]
10336fn install_pulled_delta_sources(
10337    dest: &Path,
10338    entries: &[V2StagedFile],
10339    deleted: &[String],
10340    rebuild_indexes: bool,
10341    previous: Option<&V2SyncBaseline>,
10342    next: &V2VerifiedHead,
10343) -> LinkResult<()> {
10344    match Store::open_strict(dest) {
10345        Ok(store) => {
10346            install_established_v2_delta(store, entries, deleted, rebuild_indexes, previous, next)
10347        }
10348        Err(_) => install_windows_initial_sources(dest, entries, rebuild_indexes),
10349    }
10350}
10351
10352#[cfg(not(any(unix, windows)))]
10353fn install_pulled_delta_sources(
10354    _dest: &Path,
10355    _entries: &[V2StagedFile],
10356    _deleted: &[String],
10357    _rebuild_indexes: bool,
10358    _previous: Option<&V2SyncBaseline>,
10359    _next: &V2VerifiedHead,
10360) -> LinkResult<()> {
10361    Err(LinkError::UnsupportedPlatform {
10362        operation: "atomic v2 pull install",
10363    })
10364}
10365
10366#[cfg(unix)]
10367fn install_pulled_snapshot(dest: &Path, entries: &[(String, Vec<u8>)]) -> LinkResult<()> {
10368    install_pulled_delta(dest, entries, &[], false)
10369}
10370
10371#[cfg(not(windows))]
10372fn is_safe_slug(slug: &str) -> bool {
10373    !slug.is_empty()
10374        && slug.len() <= 63
10375        && !slug.starts_with('-')
10376        && !slug.ends_with('-')
10377        && slug
10378            .bytes()
10379            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
10380}
10381
10382fn le_u16(bytes: &[u8], at: usize) -> Option<u16> {
10383    Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
10384}
10385
10386fn le_u32(bytes: &[u8], at: usize) -> Option<u32> {
10387    Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
10388}
10389
10390fn le_u64(bytes: &[u8], at: usize) -> Option<u64> {
10391    Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
10392}
10393
10394fn preflight_zip_central_directory(
10395    bytes: &[u8],
10396    offset: usize,
10397    size: usize,
10398    count: u64,
10399) -> LinkResult<()> {
10400    const CENTRAL_ENTRY_SIG: &[u8; 4] = b"PK\x01\x02";
10401    let end = offset
10402        .checked_add(size)
10403        .filter(|end| *end <= bytes.len())
10404        .ok_or_else(|| LinkError::InvalidPack {
10405            message: "ZIP central directory is out of bounds".to_string(),
10406        })?;
10407    let mut cursor = offset;
10408    for _ in 0..count {
10409        if bytes.get(cursor..cursor + 4) != Some(CENTRAL_ENTRY_SIG.as_slice()) {
10410            return Err(LinkError::InvalidPack {
10411                message: "ZIP central directory entry count is inconsistent".to_string(),
10412            });
10413        }
10414        if le_u16(bytes, cursor + 34) != Some(0) {
10415            return Err(LinkError::InvalidPack {
10416                message: "multi-disk ZIP archives are not supported".to_string(),
10417            });
10418        }
10419        let variable = [28, 30, 32].into_iter().try_fold(0usize, |total, at| {
10420            total.checked_add(le_u16(bytes, cursor + at)? as usize)
10421        });
10422        cursor = cursor
10423            .checked_add(46)
10424            .and_then(|fixed| fixed.checked_add(variable?))
10425            .filter(|cursor| *cursor <= end)
10426            .ok_or_else(|| LinkError::InvalidPack {
10427                message: "ZIP central directory entry is truncated".to_string(),
10428            })?;
10429    }
10430    if cursor != end {
10431        return Err(LinkError::InvalidPack {
10432            message: "ZIP central directory size is inconsistent".to_string(),
10433        });
10434    }
10435    Ok(())
10436}
10437
10438/// Read only the bounded ZIP trailer before `ZipArchive::new` allocates one
10439/// metadata object per central-directory entry. Supports ordinary EOCD and the
10440/// Zip64 locator/record emitted for >65,535-entry archives.
10441fn preflight_zip_entry_count(bytes: &[u8], max_entries: usize) -> LinkResult<()> {
10442    const EOCD_SIG: &[u8; 4] = b"PK\x05\x06";
10443    const ZIP64_LOCATOR_SIG: &[u8; 4] = b"PK\x06\x07";
10444    const ZIP64_EOCD_SIG: &[u8; 4] = b"PK\x06\x06";
10445    let search_start = bytes.len().saturating_sub(22 + u16::MAX as usize);
10446    let eocd = bytes[search_start..]
10447        .windows(4)
10448        .rposition(|window| window == EOCD_SIG)
10449        .map(|offset| search_start + offset)
10450        .ok_or_else(|| LinkError::InvalidPack {
10451            message: "ZIP has no end-of-central-directory record".to_string(),
10452        })?;
10453    let invalid_end = || LinkError::InvalidPack {
10454        message: "ZIP has an invalid end-of-central-directory structure".to_string(),
10455    };
10456    let comment_len = le_u16(bytes, eocd + 20).ok_or_else(invalid_end)? as usize;
10457    if eocd
10458        .checked_add(22)
10459        .and_then(|end| end.checked_add(comment_len))
10460        != Some(bytes.len())
10461    {
10462        // Do not fall back to an earlier signature. ZipArchive does, and a
10463        // fake low-count EOCD appended after a real Zip64 entry-count bomb
10464        // would otherwise bypass this allocation preflight.
10465        return Err(invalid_end());
10466    }
10467    let disk = le_u16(bytes, eocd + 4);
10468    let central_disk = le_u16(bytes, eocd + 6);
10469    if disk != Some(0) || central_disk != Some(0) {
10470        return Err(LinkError::InvalidPack {
10471            message: "multi-disk ZIP archives are not supported".to_string(),
10472        });
10473    }
10474    let entries_on_disk = le_u16(bytes, eocd + 8).ok_or_else(invalid_end)?;
10475    let ordinary = le_u16(bytes, eocd + 10).ok_or_else(invalid_end)?;
10476    if entries_on_disk != ordinary {
10477        return Err(LinkError::InvalidPack {
10478            message: "multi-disk ZIP archives are not supported".to_string(),
10479        });
10480    }
10481    let zip64_locator = eocd
10482        .checked_sub(20)
10483        .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_LOCATOR_SIG.as_slice()));
10484    let (count, central_offset, central_size) = if ordinary != u16::MAX || zip64_locator.is_none() {
10485        let central_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)? as usize;
10486        let central_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)? as usize;
10487        if central_offset
10488            .checked_add(central_size)
10489            .filter(|end| *end == eocd)
10490            .is_none()
10491        {
10492            return Err(invalid_end());
10493        }
10494        (ordinary as u64, central_offset, central_size)
10495    } else {
10496        let Some(locator) = zip64_locator else {
10497            return Err(invalid_end());
10498        };
10499        if le_u32(bytes, locator + 4) != Some(0) || le_u32(bytes, locator + 16) != Some(1) {
10500            return Err(LinkError::InvalidPack {
10501                message: "multi-disk ZIP64 archives are not supported".to_string(),
10502            });
10503        }
10504        let record = le_u64(bytes, locator + 8)
10505            .and_then(|offset| usize::try_from(offset).ok())
10506            .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_EOCD_SIG.as_slice()))
10507            .ok_or_else(|| LinkError::InvalidPack {
10508                message: "ZIP64 archive has an invalid end record".to_string(),
10509            })?;
10510        let record_size = le_u64(bytes, record + 4)
10511            .and_then(|size| usize::try_from(size).ok())
10512            .filter(|size| *size >= 44)
10513            .ok_or_else(invalid_end)?;
10514        if record
10515            .checked_add(12)
10516            .and_then(|end| end.checked_add(record_size))
10517            != Some(locator)
10518            || le_u32(bytes, record + 16) != Some(0)
10519            || le_u32(bytes, record + 20) != Some(0)
10520        {
10521            return Err(invalid_end());
10522        }
10523        let zip64_on_disk = le_u64(bytes, record + 24).ok_or_else(invalid_end)?;
10524        let zip64_total = le_u64(bytes, record + 32).ok_or_else(invalid_end)?;
10525        let central_size = le_u64(bytes, record + 40)
10526            .and_then(|size| usize::try_from(size).ok())
10527            .ok_or_else(invalid_end)?;
10528        let central_offset = le_u64(bytes, record + 48)
10529            .and_then(|offset| usize::try_from(offset).ok())
10530            .ok_or_else(invalid_end)?;
10531        if zip64_on_disk != zip64_total
10532            || central_offset
10533                .checked_add(central_size)
10534                .filter(|end| *end == record)
10535                .is_none()
10536        {
10537            return Err(invalid_end());
10538        }
10539        let legacy_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)?;
10540        let legacy_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)?;
10541        if (legacy_size != u32::MAX && legacy_size as usize != central_size)
10542            || (legacy_offset != u32::MAX && legacy_offset as usize != central_offset)
10543        {
10544            return Err(invalid_end());
10545        }
10546        (zip64_total, central_offset, central_size)
10547    };
10548    if count == 0 || count > max_entries as u64 {
10549        return Err(LinkError::InvalidPack {
10550            message: format!("invalid file count {count}"),
10551        });
10552    }
10553    preflight_zip_central_directory(bytes, central_offset, central_size, count)?;
10554    Ok(())
10555}
10556
10557fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
10558    preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)?;
10559    let mut archive =
10560        zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
10561            message: format!("ZIP parse failed: {err}"),
10562        })?;
10563    if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
10564        return Err(LinkError::InvalidPack {
10565            message: format!("invalid file count {}", archive.len()),
10566        });
10567    }
10568    let mut total = 0u64;
10569    let mut seen = std::collections::HashSet::new();
10570    let mut entries = Vec::with_capacity(archive.len());
10571    for index in 0..archive.len() {
10572        let mut file = archive
10573            .by_index(index)
10574            .map_err(|err| LinkError::InvalidPack {
10575                message: format!("ZIP entry failed: {err}"),
10576            })?;
10577        if file.is_dir() {
10578            continue;
10579        }
10580        let path = file.name().to_string();
10581        if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
10582            return Err(LinkError::UnsafePath { path });
10583        }
10584        if file
10585            .unix_mode()
10586            .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
10587        {
10588            return Err(LinkError::InvalidPack {
10589                message: format!("non-file entry `{path}`"),
10590            });
10591        }
10592        if !seen.insert(path.clone()) {
10593            return Err(LinkError::InvalidPack {
10594                message: format!("duplicate path `{path}`"),
10595            });
10596        }
10597        let remaining = MAX_STORE_BYTES.saturating_sub(total);
10598        if file.size() > remaining {
10599            return Err(LinkError::InvalidPack {
10600                message: "expanded content exceeds the 512 MB limit".to_string(),
10601            });
10602        }
10603        let mut content = Vec::new();
10604        (&mut file)
10605            .take(remaining + 1)
10606            .read_to_end(&mut content)
10607            .map_err(|err| LinkError::InvalidPack {
10608                message: format!("could not decompress `{path}`: {err}"),
10609            })?;
10610        if content.len() as u64 > remaining {
10611            return Err(LinkError::InvalidPack {
10612                message: "expanded content exceeds the 512 MB limit".to_string(),
10613            });
10614        }
10615        if content.len() as u64 != file.size() {
10616            return Err(LinkError::InvalidPack {
10617                message: format!("length mismatch for `{path}`"),
10618            });
10619        }
10620        total += content.len() as u64;
10621        entries.push((path, content));
10622    }
10623    if entries.is_empty() {
10624        return Err(LinkError::InvalidPack {
10625            message: "pack contains no files".to_string(),
10626        });
10627    }
10628    Ok(entries)
10629}
10630
10631fn verify_snapshot_manifest(entries: &[(String, Vec<u8>)], signed: &[FeedFile]) -> LinkResult<()> {
10632    let mut expected = std::collections::BTreeMap::new();
10633    for file in signed {
10634        if !safe_store_rel_path(&file.path) {
10635            return Err(LinkError::UnsafePath {
10636                path: file.path.clone(),
10637            });
10638        }
10639        if !is_sha256(&file.sha256)
10640            || expected
10641                .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
10642                .is_some()
10643        {
10644            return Err(invalid_feed(
10645                "signed snapshot manifest contains an invalid or duplicate file",
10646            ));
10647        }
10648    }
10649    if expected.len() != entries.len() {
10650        return Err(invalid_feed(
10651            "downloaded pack file set differs from the signed snapshot manifest",
10652        ));
10653    }
10654    for (path, bytes) in entries {
10655        let Some((sha256, declared_bytes)) = expected.get(path.as_str()) else {
10656            return Err(invalid_feed(format!(
10657                "downloaded pack contains unsigned path `{path}`"
10658            )));
10659        };
10660        if *declared_bytes != bytes.len() as u64
10661            || *sha256 != format!("{:x}", Sha256::digest(bytes))
10662        {
10663            return Err(invalid_feed(format!(
10664                "downloaded file `{path}` differs from its signed manifest"
10665            )));
10666        }
10667    }
10668    Ok(())
10669}
10670
10671/// Collect the files a push sends: the store's owned text — `DB.md`,
10672/// `assets.jsonl` when present, and every content `.md` under `records/` and
10673/// `sources/` (the store walk, which already excludes hidden dirs like
10674/// `.dbmd/`, the `log/` archive, and derived `index.*` catalogs; the hub
10675/// derives its own index, and local history stays local). Returns
10676/// `(store-relative path, content)` pairs, path-sorted.
10677pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
10678    require_hardened_filesystem("sync push")?;
10679    preflight_push_ownership(store)?;
10680    let mut out: Vec<(String, String)> = Vec::new();
10681    let mut total = 0u64;
10682
10683    let mut read_text = |rel: &str| -> LinkResult<String> {
10684        let bytes = store.read_bounded(Path::new(rel), MAX_STORE_BYTES.saturating_sub(total))?;
10685        total = total
10686            .checked_add(bytes.len() as u64)
10687            .ok_or_else(|| LinkError::PushTooLarge {
10688                detail: "uncompressed byte count overflow".to_string(),
10689            })?;
10690        if total > MAX_STORE_BYTES {
10691            return Err(LinkError::PushTooLarge {
10692                detail: format!("{total} uncompressed bytes"),
10693            });
10694        }
10695        String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
10696            path: rel.to_string(),
10697        })
10698    };
10699
10700    out.push(("DB.md".to_string(), read_text("DB.md")?));
10701    if store
10702        .regular_file_exists(Path::new("assets.jsonl"))
10703        .unwrap_or(false)
10704    {
10705        out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
10706    }
10707
10708    for rel in store.walk()? {
10709        let rel_str = rel.to_string_lossy().replace('\\', "/");
10710        if !safe_store_rel_path(&rel_str) {
10711            // A locally-legal name outside the hub's portable charset cannot
10712            // travel this wire; refusing beats silently dropping it.
10713            return Err(LinkError::UnsafePath { path: rel_str });
10714        }
10715        let content = read_text(&rel_str)?;
10716        out.push((rel_str, content));
10717    }
10718
10719    out.sort_by(|a, b| a.0.cmp(&b.0));
10720    Ok(out)
10721}
10722
10723/// Refuse to build a destructive whole-store snapshot from an ambiguous local
10724/// tree. Ordinary read-only walks safely prune foreign paths, but a push that
10725/// silently omitted them could delete the hosted copies of those paths.
10726fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
10727    if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
10728        return Err(LinkError::from(std::io::Error::new(
10729            std::io::ErrorKind::PermissionDenied,
10730            format!("cannot push: nested db.md store at {}", nested.display()),
10731        )));
10732    }
10733
10734    if let Some(symlink) = store.unowned_symlinks()?.into_iter().next() {
10735        return Err(LinkError::from(std::io::Error::new(
10736            std::io::ErrorKind::PermissionDenied,
10737            format!(
10738                "cannot push: {} is a symlink outside the store ownership model",
10739                symlink.display()
10740            ),
10741        )));
10742    }
10743    Ok(())
10744}
10745
10746/// Push `files` to `brain` as a whole-store snapshot — the hub's push
10747/// semantics: the hosted copy becomes exactly this set (pull first if the
10748/// hosted side may have records the local copy lacks). Client-side caps
10749/// mirror the hub's JSON-path limits so an oversized push fails before the
10750/// upload.
10751pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
10752    require_safe_ref(brain)?;
10753    let remote = verified_remote_head(cfg, brain, false)?;
10754    if files.len() > MAX_PUSH_FILES {
10755        return Err(LinkError::PushTooLarge {
10756            detail: format!("{} files", files.len()),
10757        });
10758    }
10759    let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
10760    if raw_total > MAX_STORE_BYTES {
10761        return Err(LinkError::PushTooLarge {
10762            detail: format!("{raw_total} uncompressed bytes"),
10763        });
10764    }
10765
10766    // Self-custody (a brain key is configured): the JSON fast path is
10767    // hub-signed by construction, so every push goes through the pack flow
10768    // with a locally signed entry — the hub verifies and can never sign.
10769    if cfg.brain_key.is_none() {
10770        let body = json!({
10771            "files": files
10772                .iter()
10773                .map(|(p, c)| json!({ "path": p, "content": c }))
10774                .collect::<Vec<_>>(),
10775        });
10776        if body.to_string().len() <= MAX_PUSH_BYTES {
10777            let path = format!("/api/hub/brains/{brain}/push");
10778            let pushed = ensure_ok(
10779                request(cfg, "POST", &path, Some(&body), Auth::Required)?,
10780                "sync push",
10781            )?;
10782            return Ok(pushed);
10783        }
10784    }
10785
10786    let pack = build_store_pack(files)?;
10787    if pack.len() as u64 > MAX_PACK_BYTES {
10788        return Err(LinkError::PushTooLarge {
10789            detail: format!("{} pack bytes", pack.len()),
10790        });
10791    }
10792    let sha256 = format!("{:x}", Sha256::digest(&pack));
10793    let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
10794    if let Some(key) = &cfg.brain_key {
10795        if !remote.head.verified {
10796            return Err(invalid_feed(
10797                "self-custody push requires a fully verified, unscoped feed head",
10798            ));
10799        }
10800        let identity = remote
10801            .identity
10802            .as_ref()
10803            .ok_or_else(|| invalid_feed("verified brain has no current identity"))?;
10804        let current_multikey = format!("ed25519:{}", identity.fingerprint);
10805        if key.multikey != current_multikey || key.public_key_spki != identity.public_key_spki {
10806            return Err(invalid_feed(
10807                "configured brain key is not the verified current brain identity",
10808            ));
10809        }
10810        // The verified state pins seq + prev; a concurrent writer surfaces as
10811        // the hub's 422 on commit (re-run to retry against the new head).
10812        let next_seq = remote
10813            .head
10814            .seq
10815            .checked_add(1)
10816            .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
10817        let mut manifest: Vec<WireFeedFile> = files
10818            .iter()
10819            .map(|(path, content)| WireFeedFile {
10820                path: path.clone(),
10821                sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
10822                bytes: content.len() as u64,
10823            })
10824            .collect();
10825        manifest.sort_by(|a, b| a.path.cmp(&b.path));
10826        let ts = crate::now()
10827            .with_timezone(&chrono::Utc)
10828            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
10829            .to_string();
10830        let entry = self_custody_entry(
10831            key,
10832            next_seq,
10833            ts,
10834            &sha256,
10835            &manifest,
10836            remote.head.feed_hash.as_deref(),
10837        )?;
10838        meta["entry"] = Value::String(entry);
10839    }
10840    let presigned = ensure_ok(
10841        request(
10842            cfg,
10843            "POST",
10844            &format!("/api/hub/brains/{brain}/packs/presign"),
10845            Some(&meta),
10846            Auth::Required,
10847        )?,
10848        "prepare pack upload",
10849    )?;
10850    let url = presigned
10851        .get("url")
10852        .and_then(Value::as_str)
10853        .ok_or_else(|| LinkError::InvalidPack {
10854            message: "the hub returned no upload URL".to_string(),
10855        })?;
10856    put_presigned(
10857        cfg,
10858        url,
10859        presigned.get("headers").unwrap_or(&Value::Null),
10860        &pack,
10861    )?;
10862    let committed = ensure_ok(
10863        request(
10864            cfg,
10865            "POST",
10866            &format!("/api/hub/brains/{brain}/packs/commit"),
10867            Some(&meta),
10868            Auth::Required,
10869        )?,
10870        "commit pack",
10871    )?;
10872    Ok(committed)
10873}
10874
10875fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
10876    const LOCAL_HEADER: u32 = 0x0403_4b50;
10877    const CENTRAL_HEADER: u32 = 0x0201_4b50;
10878    const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
10879    const VERSION_20: u16 = 20;
10880    const MADE_BY_UNIX_20: u16 = (3 << 8) | VERSION_20;
10881    const UTF8_FLAG: u16 = 1 << 11;
10882    const STORED: u16 = 0;
10883    const DOS_TIME_MIDNIGHT: u16 = 0;
10884    const DOS_DATE_1980_01_01: u16 = (1 << 5) | 1;
10885    const UNIX_REGULAR_0600: u32 = 0o100600 << 16;
10886
10887    struct CentralEntry<'a> {
10888        name: &'a [u8],
10889        crc32: u32,
10890        size: u32,
10891        local_offset: u32,
10892    }
10893
10894    fn push_u16(out: &mut Vec<u8>, value: u16) {
10895        out.extend_from_slice(&value.to_le_bytes());
10896    }
10897
10898    fn push_u32(out: &mut Vec<u8>, value: u32) {
10899        out.extend_from_slice(&value.to_le_bytes());
10900    }
10901
10902    if files.is_empty() {
10903        return Err(LinkError::InvalidPack {
10904            message: "cannot create an empty snapshot pack".to_string(),
10905        });
10906    }
10907    if files.len() > u16::MAX as usize {
10908        return Err(LinkError::PushTooLarge {
10909            detail: format!(
10910                "{} files (canonical ZIP32 packs cap at {})",
10911                files.len(),
10912                u16::MAX
10913            ),
10914        });
10915    }
10916
10917    let mut sorted: Vec<_> = files.iter().collect();
10918    sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
10919    let mut previous: Option<&str> = None;
10920    for (path, content) in &sorted {
10921        if !safe_store_rel_path(path) {
10922            return Err(LinkError::UnsafePath {
10923                path: (*path).clone(),
10924            });
10925        }
10926        if previous == Some(path.as_str()) {
10927            return Err(LinkError::InvalidPack {
10928                message: format!("duplicate path `{path}`"),
10929            });
10930        }
10931        previous = Some(path.as_str());
10932        u32::try_from(content.len()).map_err(|_| LinkError::PushTooLarge {
10933            detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
10934        })?;
10935    }
10936
10937    let mut out = Vec::new();
10938    let mut central = Vec::with_capacity(sorted.len());
10939    for (path, content) in sorted {
10940        let name = path.as_bytes();
10941        let name_len = u16::try_from(name.len()).map_err(|_| LinkError::InvalidPack {
10942            message: format!("ZIP entry name is too long: `{path}`"),
10943        })?;
10944        let bytes = content.as_bytes();
10945        let size = u32::try_from(bytes.len()).map_err(|_| LinkError::PushTooLarge {
10946            detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
10947        })?;
10948        let local_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
10949            detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
10950        })?;
10951        let crc32 = crc32fast::hash(bytes);
10952
10953        // Canonical local header: sizes and CRC are known up front. Bit 3 is
10954        // deliberately clear, so there is no trailing data descriptor.
10955        push_u32(&mut out, LOCAL_HEADER);
10956        push_u16(&mut out, VERSION_20);
10957        push_u16(&mut out, UTF8_FLAG);
10958        push_u16(&mut out, STORED);
10959        push_u16(&mut out, DOS_TIME_MIDNIGHT);
10960        push_u16(&mut out, DOS_DATE_1980_01_01);
10961        push_u32(&mut out, crc32);
10962        push_u32(&mut out, size);
10963        push_u32(&mut out, size);
10964        push_u16(&mut out, name_len);
10965        push_u16(&mut out, 0); // no local extra data
10966        out.extend_from_slice(name);
10967        out.extend_from_slice(bytes);
10968
10969        central.push(CentralEntry {
10970            name,
10971            crc32,
10972            size,
10973            local_offset,
10974        });
10975    }
10976
10977    let central_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
10978        detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
10979    })?;
10980    for entry in &central {
10981        push_u32(&mut out, CENTRAL_HEADER);
10982        push_u16(&mut out, MADE_BY_UNIX_20);
10983        push_u16(&mut out, VERSION_20);
10984        push_u16(&mut out, UTF8_FLAG);
10985        push_u16(&mut out, STORED);
10986        push_u16(&mut out, DOS_TIME_MIDNIGHT);
10987        push_u16(&mut out, DOS_DATE_1980_01_01);
10988        push_u32(&mut out, entry.crc32);
10989        push_u32(&mut out, entry.size);
10990        push_u32(&mut out, entry.size);
10991        push_u16(&mut out, entry.name.len() as u16);
10992        push_u16(&mut out, 0); // no central extra data
10993        push_u16(&mut out, 0); // no file comment
10994        push_u16(&mut out, 0); // disk number
10995        push_u16(&mut out, 0); // internal attributes
10996        push_u32(&mut out, UNIX_REGULAR_0600);
10997        push_u32(&mut out, entry.local_offset);
10998        out.extend_from_slice(entry.name);
10999    }
11000    let central_size = u32::try_from(out.len())
11001        .ok()
11002        .and_then(|end| end.checked_sub(central_offset))
11003        .ok_or_else(|| LinkError::PushTooLarge {
11004            detail: "canonical ZIP32 central directory exceeds its limit".to_string(),
11005        })?;
11006    let entry_count = central.len() as u16;
11007
11008    push_u32(&mut out, END_OF_CENTRAL_DIRECTORY);
11009    push_u16(&mut out, 0); // this disk
11010    push_u16(&mut out, 0); // central directory disk
11011    push_u16(&mut out, entry_count);
11012    push_u16(&mut out, entry_count);
11013    push_u32(&mut out, central_size);
11014    push_u32(&mut out, central_offset);
11015    push_u16(&mut out, 0); // no archive comment
11016
11017    if out.len() > u32::MAX as usize {
11018        return Err(LinkError::PushTooLarge {
11019            detail: "canonical ZIP32 pack exceeds 4 GiB".to_string(),
11020        });
11021    }
11022    Ok(out)
11023}
11024
11025// ─────────────────────────────────────────────────────────────────────────────
11026// grant — issue / list / revoke capabilities (owner-side)
11027// ─────────────────────────────────────────────────────────────────────────────
11028
11029/// The two capabilities a v0 hub enforces.
11030#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11031pub enum Capability {
11032    /// Read the granted slice.
11033    Read,
11034    /// Read and push (whole-store; a path-scoped grant is read-only).
11035    Write,
11036}
11037
11038impl Capability {
11039    /// The wire form.
11040    pub fn as_str(self) -> &'static str {
11041        match self {
11042            Capability::Read => "read",
11043            Capability::Write => "write",
11044        }
11045    }
11046}
11047
11048/// Issue (or refresh) a grant on `brain` to `grantee` — a hub principal named
11049/// by email in v0 (the protocol's near-term simplification; key-named
11050/// grantees arrive with the signing layer). `scope` is a store-path prefix
11051/// (the hub's enforcement unit); `until` an ISO 8601 expiry, absent = until
11052/// revoked.
11053pub fn grant_issue(
11054    cfg: &HubConfig,
11055    brain: &str,
11056    grantee: &str,
11057    can: Capability,
11058    scope: Option<&str>,
11059    until: Option<&str>,
11060) -> LinkResult<Value> {
11061    require_safe_ref(brain)?;
11062    // Grantee shape decides the axis: a base64url Ed25519 SPKI is a bare
11063    // multikey holder (link.md §6 cross-party keys — no hub account; the
11064    // printed `publicKeySpki` from `dbmd key generate`); anything else is a
11065    // hub principal named by email.
11066    let is_key_grantee = URL_SAFE_NO_PAD
11067        .decode(grantee)
11068        .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
11069        .unwrap_or(false);
11070    if let Some(head) = v2_verified_head(cfg, brain)? {
11071        if is_key_grantee {
11072            let scope = scope.unwrap_or("");
11073            let preset = match can {
11074                Capability::Read => "viewer",
11075                Capability::Write => "editor",
11076            };
11077            let entropy = format!(
11078                "{}\0{}\0{}\0{}\0{}\0{}\0{}",
11079                normalized_origin(&cfg.hub)?,
11080                head.brain_id,
11081                head.control_revision,
11082                grantee,
11083                preset,
11084                scope,
11085                until.unwrap_or("")
11086            );
11087            let mut body = json!({
11088                "context": "external",
11089                "expected_control_revision": head.control_revision,
11090                "mutation_id": format!("dbmd-grant-{}", content_sha256(entropy.as_bytes())),
11091                "preset": preset,
11092                "principal_kind": "key",
11093                "public_key": grantee,
11094                "scope": scope,
11095                "scope_kind": "prefix",
11096            });
11097            if let Some(value) = until {
11098                body["expires_at"] = json!(value);
11099            }
11100            let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
11101            let response = ensure_ok(
11102                request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11103                "v2 grant issue",
11104            )?;
11105            let expected_fingerprint = identity_fingerprint(grantee)?;
11106            if response.get("v").and_then(Value::as_u64) != Some(2)
11107                || response
11108                    .get("id")
11109                    .and_then(Value::as_str)
11110                    .is_none_or(|id| !crate::ulid::is_ulid(id))
11111                || response.get("principal_kind").and_then(Value::as_str) != Some("key")
11112                || response.get("principal_id").and_then(Value::as_str)
11113                    != Some(expected_fingerprint.as_str())
11114                || response
11115                    .get("control_revision")
11116                    .and_then(Value::as_str)
11117                    .is_none_or(|value| !is_sha256(value))
11118            {
11119                return Err(invalid_feed(
11120                    "v2 grant issue response is not authority-bound",
11121                ));
11122            }
11123            return Ok(response);
11124        }
11125        // Account invitations remain a product workflow. The compatibility
11126        // endpoint resolves email/account identity, then commits a native v2
11127        // grant with the same control-plane recovery boundary. The v2 head
11128        // above is already the authenticated authority checkpoint; never
11129        // fall through to the retired v1 feed verifier for this request.
11130        let mut body = json!({ "email": grantee, "capability": can.as_str() });
11131        if let Some(value) = scope {
11132            body["scopePrefix"] = json!(value);
11133        }
11134        if let Some(value) = until {
11135            body["expiresAt"] = json!(value);
11136        }
11137        let path = format!("/api/hub/brains/{}/grants", head.brain_id);
11138        return ensure_ok(
11139            request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11140            "account grant issue",
11141        );
11142    }
11143    let _ = verified_remote_head(cfg, brain, false)?;
11144    let mut body = if is_key_grantee {
11145        json!({ "keySpki": grantee, "capability": can.as_str() })
11146    } else {
11147        json!({ "email": grantee, "capability": can.as_str() })
11148    };
11149    if let Some(s) = scope {
11150        body["scopePrefix"] = json!(s);
11151    }
11152    if let Some(u) = until {
11153        body["expiresAt"] = json!(u);
11154    }
11155    let path = format!("/api/hub/brains/{brain}/grants");
11156    ensure_ok(
11157        request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11158        "grant issue",
11159    )
11160}
11161
11162/// List the active grants (and pending invites) on `brain`. Owner-side.
11163pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
11164    require_safe_ref(brain)?;
11165    if let Some(head) = v2_verified_head(cfg, brain)? {
11166        let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
11167        let response = ensure_ok(
11168            request(cfg, "GET", &path, None, Auth::Required)?,
11169            "v2 grant list",
11170        )?;
11171        if response.get("v").and_then(Value::as_u64) != Some(2)
11172            || response.get("control_revision").and_then(Value::as_str)
11173                != Some(head.control_revision.as_str())
11174            || !response.get("grants").is_some_and(Value::is_array)
11175        {
11176            return Err(invalid_feed(
11177                "v2 grant list is not bound to the verified authority",
11178            ));
11179        }
11180        return Ok(response);
11181    }
11182    let _ = verified_remote_head(cfg, brain, false)?;
11183    let path = format!("/api/hub/brains/{brain}/grants");
11184    ensure_ok(
11185        request(cfg, "GET", &path, None, Auth::Required)?,
11186        "grant list",
11187    )
11188}
11189
11190/// Revoke a grant (or cancel a pending invite) by id. Owner-side; revocation
11191/// is soft on the hub (the audit trail survives).
11192pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
11193    require_safe_ref(brain)?;
11194    require_safe_grant_id(grant_id)?;
11195    if let Some(head) = v2_verified_head(cfg, brain)? {
11196        let entropy = format!(
11197            "{}\0{}\0{}\0{}",
11198            normalized_origin(&cfg.hub)?,
11199            head.brain_id,
11200            head.control_revision,
11201            grant_id
11202        );
11203        let body = json!({
11204            "expected_control_revision": head.control_revision,
11205            "mutation_id": format!("dbmd-revoke-{}", content_sha256(entropy.as_bytes())),
11206        });
11207        let path = format!("/api/hub/brains/{}/v2/grants/{grant_id}", head.brain_id);
11208        let response = ensure_ok(
11209            request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
11210            "v2 grant revoke",
11211        )?;
11212        if response.get("v").and_then(Value::as_u64) != Some(2)
11213            || response.get("id").and_then(Value::as_str) != Some(grant_id)
11214            || response.get("revoked").and_then(Value::as_bool) != Some(true)
11215            || response
11216                .get("control_revision")
11217                .and_then(Value::as_str)
11218                .is_none_or(|value| !is_sha256(value))
11219        {
11220            return Err(invalid_feed(
11221                "v2 grant revocation response is not authority-bound",
11222            ));
11223        }
11224        return Ok(response);
11225    }
11226    let _ = verified_remote_head(cfg, brain, false)?;
11227    let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
11228    ensure_ok(
11229        request(cfg, "DELETE", &path, None, Auth::Required)?,
11230        "grant revoke",
11231    )
11232}
11233
11234// ─────────────────────────────────────────────────────────────────────────────
11235// permissioned v2 proposals — inspect, accept, reject
11236// ─────────────────────────────────────────────────────────────────────────────
11237
11238#[derive(Debug)]
11239struct VerifiedV2Proposal {
11240    value: Value,
11241    changes: Value,
11242    blobs: Vec<(String, u64, String)>,
11243}
11244
11245fn require_proposal_id(id: &str) -> LinkResult<()> {
11246    if crate::ulid::is_ulid(id) {
11247        Ok(())
11248    } else {
11249        Err(invalid_feed("proposal id is not a lowercase ULID"))
11250    }
11251}
11252
11253fn verified_v2_proposal(
11254    cfg: &HubConfig,
11255    head: &V2VerifiedHead,
11256    proposal_id: &str,
11257) -> LinkResult<VerifiedV2Proposal> {
11258    require_proposal_id(proposal_id)?;
11259    if head.view_kind != "full" {
11260        return Err(invalid_feed(
11261            "proposal review requires a full readable view",
11262        ));
11263    }
11264    let path = format!(
11265        "/api/hub/brains/{}/v2/proposals/{proposal_id}",
11266        head.brain_id
11267    );
11268    let value = ensure_ok(
11269        request_capped(
11270            cfg,
11271            "GET",
11272            &path,
11273            None,
11274            Auth::Required,
11275            MAX_FEED_RESPONSE_BYTES,
11276        )?,
11277        "v2 proposal",
11278    )?;
11279    verify_v2_proposal_value(head, proposal_id, value)
11280}
11281
11282fn verify_v2_proposal_value(
11283    head: &V2VerifiedHead,
11284    proposal_id: &str,
11285    value: Value,
11286) -> LinkResult<VerifiedV2Proposal> {
11287    if value.get("v").and_then(Value::as_u64) != Some(2) {
11288        return Err(invalid_feed("proposal response has an invalid version"));
11289    }
11290    let proposal = value
11291        .get("proposal")
11292        .and_then(Value::as_object)
11293        .ok_or_else(|| invalid_feed("proposal response has no proposal"))?;
11294    if proposal.get("id").and_then(Value::as_str) != Some(proposal_id) {
11295        return Err(invalid_feed("proposal response changed its id"));
11296    }
11297    let payload_hash = proposal
11298        .get("payload_sha256")
11299        .and_then(Value::as_str)
11300        .filter(|hash| is_sha256(hash))
11301        .ok_or_else(|| invalid_feed("proposal has no payload address"))?;
11302    let clear_hash = proposal
11303        .get("clear_sha256")
11304        .and_then(Value::as_str)
11305        .filter(|hash| is_sha256(hash))
11306        .ok_or_else(|| invalid_feed("proposal has no clear payload digest"))?;
11307    let submission_hash = proposal
11308        .get("submission_claim_sha256")
11309        .and_then(Value::as_str)
11310        .filter(|hash| is_sha256(hash))
11311        .ok_or_else(|| invalid_feed("proposal has no submission claim address"))?;
11312    let submission = STANDARD
11313        .decode(
11314            proposal
11315                .get("submission_claim_base64")
11316                .and_then(Value::as_str)
11317                .ok_or_else(|| invalid_feed("proposal has no submission claim"))?,
11318        )
11319        .map_err(|_| invalid_feed("proposal submission claim is not base64"))?;
11320    let submission_value: Value = serde_json::from_slice(&submission)
11321        .map_err(|_| invalid_feed("proposal submission claim is not JSON"))?;
11322    if crate::linkmd_v2::canonical_bytes(&submission_value)
11323        .map_err(|error| invalid_feed(error.to_string()))?
11324        != submission
11325        || crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &submission)
11326            .map_err(|error| invalid_feed(error.to_string()))?
11327            != submission_hash
11328    {
11329        return Err(invalid_feed(
11330            "proposal submission claim is not canonical or addressed",
11331        ));
11332    }
11333    let envelope = submission_value
11334        .as_object()
11335        .ok_or_else(|| invalid_feed("proposal submission claim is not an object"))?;
11336    let claim = envelope
11337        .get("claim")
11338        .ok_or_else(|| invalid_feed("proposal submission claim body is missing"))?;
11339    let claim_object = claim
11340        .as_object()
11341        .ok_or_else(|| invalid_feed("proposal submission claim body is not an object"))?;
11342    let actor_root = claim_object
11343        .get("actor_root")
11344        .and_then(Value::as_object)
11345        .ok_or_else(|| invalid_feed("proposal submission actor root is missing"))?;
11346    let public_key = envelope
11347        .get("public_key")
11348        .and_then(Value::as_str)
11349        .ok_or_else(|| invalid_feed("proposal submission signer is missing"))?;
11350    let fingerprint = envelope
11351        .get("fingerprint")
11352        .and_then(Value::as_str)
11353        .ok_or_else(|| invalid_feed("proposal submission fingerprint is missing"))?;
11354    let signature = envelope
11355        .get("sig")
11356        .and_then(Value::as_str)
11357        .ok_or_else(|| invalid_feed("proposal submission signature is missing"))?;
11358    let claim_bytes = crate::linkmd_v2::canonical_bytes(claim)
11359        .map_err(|error| invalid_feed(error.to_string()))?;
11360    let der = verify_v2_spki_signature(public_key, &claim_bytes, signature)?;
11361    let signer = format!("{fingerprint}:{public_key}");
11362    let actor_class = actor_root.get("actor_class").and_then(Value::as_str);
11363    let grants = actor_root.get("grants").and_then(Value::as_array);
11364    let grants_are_canonical = grants.is_some_and(|items| {
11365        let mut prior: Option<&str> = None;
11366        items.iter().all(|item| {
11367            let Some(grant) = item.as_str() else {
11368                return false;
11369            };
11370            if !crate::ulid::is_ulid(grant) || prior.is_some_and(|value| value >= grant) {
11371                return false;
11372            }
11373            prior = Some(grant);
11374            true
11375        })
11376    });
11377    let optional_actor_field = |name: &str| {
11378        actor_root.get(name).is_some_and(|value| {
11379            value.is_null() || value.as_str().is_some_and(|text| !text.is_empty())
11380        })
11381    };
11382    let submitted_at = claim_object.get("submitted_at").and_then(Value::as_str);
11383    if claim_object.get("v").and_then(Value::as_u64) != Some(2)
11384        || format!("{:x}", Sha256::digest(&der)) != fingerprint
11385        || head
11386            .trust
11387            .hub_signer
11388            .as_ref()
11389            .is_some_and(|known| known != &signer)
11390        || !matches!(
11391            actor_class,
11392            Some(
11393                "user"
11394                    | "owned_agent"
11395                    | "foreign_key"
11396                    | "curation"
11397                    | "inbox"
11398                    | "restore"
11399                    | "migration"
11400                    | "operator_recovery"
11401            )
11402        )
11403        || actor_root
11404            .get("principal")
11405            .and_then(Value::as_str)
11406            .is_none_or(|value| value.is_empty())
11407        || actor_root
11408            .get("credential")
11409            .and_then(Value::as_str)
11410            .is_none_or(|value| value.is_empty())
11411        || !optional_actor_field("organization")
11412        || !optional_actor_field("role")
11413        || !grants_are_canonical
11414        || claim.get("brain").and_then(Value::as_str) != Some(head.brain_id.as_str())
11415        || claim.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
11416        || !claim_object
11417            .get("mutation_id")
11418            .and_then(Value::as_str)
11419            .is_some_and(|value| {
11420                !value.is_empty()
11421                    && value.len() <= 128
11422                    && value.chars().enumerate().all(|(index, char)| {
11423                        char.is_ascii_alphanumeric()
11424                            || (index > 0 && matches!(char, '.' | '_' | ':' | '-'))
11425                    })
11426            })
11427        || claim.get("payload_sha256").and_then(Value::as_str) != Some(payload_hash)
11428        || claim.get("clear_sha256").and_then(Value::as_str) != Some(clear_hash)
11429        || !claim_object
11430            .get("control_revision")
11431            .and_then(Value::as_str)
11432            .is_some_and(is_sha256)
11433        || submitted_at.is_none_or(|value| {
11434            chrono::DateTime::parse_from_rfc3339(value).is_err()
11435                || proposal.get("submitted_at").and_then(Value::as_str) != Some(value)
11436        })
11437        || !proposal
11438            .get("state")
11439            .and_then(Value::as_str)
11440            .is_some_and(|value| matches!(value, "pending" | "accepted" | "rejected" | "expired"))
11441        || proposal
11442            .get("expires_at")
11443            .and_then(Value::as_str)
11444            .is_none_or(|value| chrono::DateTime::parse_from_rfc3339(value).is_err())
11445        || proposal
11446            .get("proposer")
11447            .and_then(Value::as_object)
11448            .and_then(|value| value.get("class"))
11449            .and_then(Value::as_str)
11450            != actor_class
11451    {
11452        return Err(invalid_feed(
11453            "proposal submission claim does not bind the verified proposal",
11454        ));
11455    }
11456    let changes_b64 = proposal
11457        .get("changes_base64")
11458        .and_then(Value::as_str)
11459        .ok_or_else(|| invalid_feed("proposal has no changeset"))?;
11460    let changes_bytes = STANDARD
11461        .decode(changes_b64)
11462        .map_err(|_| invalid_feed("proposal changeset is not base64"))?;
11463    let changes: Value = serde_json::from_slice(&changes_bytes)
11464        .map_err(|_| invalid_feed("proposal changeset is not JSON"))?;
11465    if crate::linkmd_v2::canonical_bytes(&changes)
11466        .map_err(|error| invalid_feed(error.to_string()))?
11467        != changes_bytes
11468        || changes.get("v").and_then(Value::as_u64) != Some(2)
11469        || !changes.get("operations").is_some_and(Value::is_array)
11470    {
11471        return Err(invalid_feed("proposal changeset is not canonical v2"));
11472    }
11473    let blob_values = proposal
11474        .get("blobs")
11475        .and_then(Value::as_array)
11476        .ok_or_else(|| invalid_feed("proposal has no blob declarations"))?;
11477    let mut blobs = Vec::with_capacity(blob_values.len());
11478    let mut descriptor_blobs = Vec::with_capacity(blob_values.len());
11479    let mut prior_hash: Option<String> = None;
11480    for item in blob_values {
11481        let hash = item
11482            .get("sha256")
11483            .and_then(Value::as_str)
11484            .filter(|hash| is_sha256(hash))
11485            .ok_or_else(|| invalid_feed("proposal blob has no address"))?;
11486        let bytes = item
11487            .get("bytes")
11488            .and_then(Value::as_u64)
11489            .filter(|bytes| *bytes <= MAX_STORE_BYTES)
11490            .ok_or_else(|| invalid_feed("proposal blob has an invalid size"))?;
11491        if prior_hash.as_deref().is_some_and(|prior| prior >= hash) {
11492            return Err(invalid_feed(
11493                "proposal blob declarations are not unique and sorted",
11494            ));
11495        }
11496        prior_hash = Some(hash.to_string());
11497        let endpoint = item
11498            .get("endpoint")
11499            .and_then(Value::as_str)
11500            .ok_or_else(|| invalid_feed("proposal blob has no endpoint"))?;
11501        let expected_endpoint = format!(
11502            "/api/hub/brains/{}/v2/proposals/{proposal_id}/blob?sha256={hash}",
11503            head.brain_id
11504        );
11505        if endpoint != expected_endpoint {
11506            return Err(invalid_feed("proposal blob endpoint is not origin-bound"));
11507        }
11508        descriptor_blobs.push(json!({ "bytes": bytes, "sha256": hash }));
11509        blobs.push((hash.to_string(), bytes, endpoint.to_string()));
11510    }
11511    let descriptor = json!({
11512        "base": proposal.get("base").cloned().unwrap_or(Value::Null),
11513        "blobs": descriptor_blobs,
11514        "changes_base64": changes_b64,
11515        "rebase": proposal.get("rebase").cloned().unwrap_or(Value::Null),
11516        "v": 2,
11517    });
11518    let descriptor_bytes = crate::linkmd_v2::canonical_bytes(&descriptor)
11519        .map_err(|error| invalid_feed(error.to_string()))?;
11520    if content_sha256(&descriptor_bytes) != clear_hash {
11521        return Err(invalid_feed(
11522            "proposal clear payload differs from its signed submission claim",
11523        ));
11524    }
11525    Ok(VerifiedV2Proposal {
11526        value,
11527        changes,
11528        blobs,
11529    })
11530}
11531
11532pub fn proposal_list(
11533    cfg: &HubConfig,
11534    brain: &str,
11535    state: &str,
11536    after: Option<&str>,
11537    limit: usize,
11538) -> LinkResult<Value> {
11539    require_safe_ref(brain)?;
11540    if !matches!(state, "pending" | "accepted" | "rejected" | "expired") {
11541        return Err(invalid_feed("proposal state is invalid"));
11542    }
11543    if after.is_some_and(|value| !crate::ulid::is_ulid(value)) {
11544        return Err(invalid_feed("proposal cursor is invalid"));
11545    }
11546    let head = v2_verified_head(cfg, brain)?
11547        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11548    let path = format!(
11549        "/api/hub/brains/{}/v2/proposals?state={state}&limit={}{}",
11550        head.brain_id,
11551        limit.clamp(1, 100),
11552        after.map_or_else(String::new, |value| format!("&after={value}"))
11553    );
11554    ensure_ok(
11555        request_capped(
11556            cfg,
11557            "GET",
11558            &path,
11559            None,
11560            Auth::Required,
11561            MAX_FEED_RESPONSE_BYTES,
11562        )?,
11563        "v2 proposal list",
11564    )
11565}
11566
11567pub fn proposal_show(cfg: &HubConfig, brain: &str, proposal_id: &str) -> LinkResult<Value> {
11568    require_safe_ref(brain)?;
11569    let head = v2_verified_head(cfg, brain)?
11570        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11571    Ok(verified_v2_proposal(cfg, &head, proposal_id)?.value)
11572}
11573
11574pub fn proposal_reject(
11575    cfg: &HubConfig,
11576    brain: &str,
11577    proposal_id: &str,
11578    mutation_id: &str,
11579    reason: &str,
11580) -> LinkResult<Value> {
11581    require_safe_ref(brain)?;
11582    require_proposal_id(proposal_id)?;
11583    let head = v2_verified_head(cfg, brain)?
11584        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11585    let _ = verified_v2_proposal(cfg, &head, proposal_id)?;
11586    let body = json!({
11587        "mutation_id": mutation_id,
11588        "control_revision": head.control_revision,
11589        "reason": reason,
11590    });
11591    let path = format!(
11592        "/api/hub/brains/{}/v2/proposals/{proposal_id}",
11593        head.brain_id
11594    );
11595    ensure_ok(
11596        request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
11597        "v2 proposal rejection",
11598    )
11599}
11600
11601pub fn proposal_accept_exact(
11602    cfg: &HubConfig,
11603    brain: &str,
11604    proposal_id: &str,
11605    mutation_id: &str,
11606    reason: &str,
11607) -> LinkResult<Value> {
11608    require_safe_ref(brain)?;
11609    require_proposal_id(proposal_id)?;
11610    let head = v2_verified_head(cfg, brain)?
11611        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11612    let proposal = verified_v2_proposal(cfg, &head, proposal_id)?;
11613    let operations = proposal
11614        .changes
11615        .get("operations")
11616        .and_then(Value::as_array)
11617        .cloned()
11618        .ok_or_else(|| invalid_feed("proposal changeset has no operations"))?;
11619    if operations.is_empty() || operations.len() > MAX_PUSH_FILES {
11620        return Err(invalid_feed("proposal operation count is invalid"));
11621    }
11622    let mut downloaded = std::collections::BTreeMap::new();
11623    for (hash, bytes, endpoint) in &proposal.blobs {
11624        let body = ensure_raw_ok(
11625            request_raw(cfg, "GET", endpoint, None, Auth::Required, *bytes)?,
11626            "v2 proposal blob",
11627        )?;
11628        if body.len() as u64 != *bytes || content_sha256(&body) != *hash {
11629            return Err(invalid_feed("proposal blob does not match its declaration"));
11630        }
11631        downloaded.insert(hash.clone(), body);
11632    }
11633    let remote = files_for_v2_view(
11634        &head,
11635        v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
11636    );
11637    let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
11638    let mut expected_candidate = remote.clone();
11639    let mut expected_candidate_assets = remote_assets;
11640    for operation in &operations {
11641        let op = operation
11642            .get("op")
11643            .and_then(Value::as_str)
11644            .ok_or_else(|| invalid_feed("proposal operation has no kind"))?;
11645        match op {
11646            "put" | "restore" => {
11647                let path = operation
11648                    .get("path")
11649                    .and_then(Value::as_str)
11650                    .ok_or_else(|| invalid_feed("proposal write has no path"))?;
11651                crate::linkmd_v2::normalize_path(path)
11652                    .map_err(|error| invalid_feed(error.to_string()))?;
11653                let hash = operation
11654                    .get("blob")
11655                    .and_then(Value::as_str)
11656                    .filter(|hash| is_sha256(hash))
11657                    .ok_or_else(|| invalid_feed("proposal write has no blob"))?;
11658                let bytes = operation
11659                    .get("bytes")
11660                    .and_then(Value::as_u64)
11661                    .ok_or_else(|| invalid_feed("proposal write has no size"))?;
11662                expected_candidate.insert(
11663                    path.to_string(),
11664                    V2BaselineFile {
11665                        sha256: hash.to_string(),
11666                        bytes,
11667                        proof: None,
11668                    },
11669                );
11670            }
11671            "delete" | "withdraw_from_hosting" => {
11672                let path = operation
11673                    .get("path")
11674                    .and_then(Value::as_str)
11675                    .ok_or_else(|| invalid_feed("proposal removal has no path"))?;
11676                crate::linkmd_v2::normalize_path(path)
11677                    .map_err(|error| invalid_feed(error.to_string()))?;
11678                expected_candidate.remove(path);
11679            }
11680            "rename" => {
11681                let from = operation
11682                    .get("from")
11683                    .and_then(Value::as_str)
11684                    .ok_or_else(|| invalid_feed("proposal rename has no source"))?;
11685                let to = operation
11686                    .get("to")
11687                    .and_then(Value::as_str)
11688                    .ok_or_else(|| invalid_feed("proposal rename has no destination"))?;
11689                crate::linkmd_v2::normalize_path(from)
11690                    .and_then(|_| crate::linkmd_v2::normalize_path(to))
11691                    .map_err(|error| invalid_feed(error.to_string()))?;
11692                let hash = operation
11693                    .get("blob")
11694                    .and_then(Value::as_str)
11695                    .filter(|hash| is_sha256(hash))
11696                    .ok_or_else(|| invalid_feed("proposal rename has no blob"))?;
11697                let bytes = operation
11698                    .get("bytes")
11699                    .and_then(Value::as_u64)
11700                    .ok_or_else(|| invalid_feed("proposal rename has no size"))?;
11701                expected_candidate.remove(from);
11702                expected_candidate.insert(
11703                    to.to_string(),
11704                    V2BaselineFile {
11705                        sha256: hash.to_string(),
11706                        bytes,
11707                        proof: None,
11708                    },
11709                );
11710            }
11711            "asset_delete" => {
11712                let path = operation
11713                    .get("path")
11714                    .and_then(Value::as_str)
11715                    .ok_or_else(|| invalid_feed("proposal asset delete has no path"))?;
11716                expected_candidate_assets.remove(path);
11717            }
11718            "asset_withdraw" => {
11719                let path = operation
11720                    .get("path")
11721                    .and_then(Value::as_str)
11722                    .ok_or_else(|| invalid_feed("proposal asset withdrawal has no path"))?;
11723                let asset = expected_candidate_assets
11724                    .get_mut(path)
11725                    .ok_or_else(|| invalid_feed("proposal withdraws an unknown asset"))?;
11726                asset.disposition = "withheld".to_string();
11727                asset.leaf_hash.clear();
11728            }
11729            "asset_put" | "asset_resume" => {
11730                let path = operation
11731                    .get("path")
11732                    .and_then(Value::as_str)
11733                    .ok_or_else(|| invalid_feed("proposal asset write has no path"))?;
11734                let asset = operation
11735                    .get("asset")
11736                    .and_then(Value::as_object)
11737                    .ok_or_else(|| invalid_feed("proposal asset write has no value"))?;
11738                let blob_sha256 = asset
11739                    .get("blob_sha256")
11740                    .and_then(Value::as_str)
11741                    .filter(|hash| is_sha256(hash))
11742                    .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
11743                let bytes = asset
11744                    .get("bytes")
11745                    .and_then(Value::as_u64)
11746                    .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
11747                let media_type = asset
11748                    .get("media_type")
11749                    .and_then(Value::as_str)
11750                    .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
11751                let wrappers = asset
11752                    .get("wrappers")
11753                    .and_then(Value::as_array)
11754                    .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
11755                    .iter()
11756                    .map(|wrapper| {
11757                        wrapper
11758                            .as_str()
11759                            .map(str::to_string)
11760                            .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
11761                    })
11762                    .collect::<LinkResult<Vec<_>>>()?;
11763                let required = asset
11764                    .get("required")
11765                    .and_then(Value::as_bool)
11766                    .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
11767                let disposition = asset
11768                    .get("disposition")
11769                    .and_then(Value::as_str)
11770                    .filter(|value| matches!(*value, "hosted" | "withheld"))
11771                    .ok_or_else(|| invalid_feed("proposal asset disposition is invalid"))?;
11772                expected_candidate_assets.insert(
11773                    path.to_string(),
11774                    V2BaselineAsset {
11775                        blob_sha256: blob_sha256.to_string(),
11776                        bytes,
11777                        media_type: media_type.to_string(),
11778                        wrappers,
11779                        required,
11780                        disposition: disposition.to_string(),
11781                        leaf_hash: String::new(),
11782                    },
11783                );
11784            }
11785            _ => return Err(invalid_feed("proposal operation kind is unsupported")),
11786        }
11787    }
11788    let base = head.pointer.as_ref().map(|pointer| {
11789        json!({
11790            "seq": pointer.seq,
11791            "commit_hash": pointer.commit_hash,
11792            "content_root": pointer.content_root,
11793            "asset_root": pointer.asset_root,
11794        })
11795    });
11796    let mut body = json!({
11797        "mutation_id": mutation_id,
11798        "base": base,
11799        "rebase": "strict",
11800        "reason": reason,
11801        "operations": operations,
11802        "blobs": downloaded
11803            .iter()
11804            .map(|(sha256, bytes)| json!({
11805                "sha256": sha256,
11806                "bytes": bytes.len(),
11807                "content_base64": STANDARD.encode(bytes),
11808            }))
11809            .collect::<Vec<_>>(),
11810        "proposal_id": proposal_id,
11811        "proposal_mode": "exact",
11812    });
11813    let changed_bytes = downloaded.values().try_fold(0_usize, |total, bytes| {
11814        total
11815            .checked_add(bytes.len())
11816            .ok_or_else(|| LinkError::PushTooLarge {
11817                detail: "proposal changed-byte total overflow".to_string(),
11818            })
11819    })?;
11820    if changed_bytes > 3 * 1024 * 1024 || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
11821        let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
11822        for operation in &operations {
11823            let Some(kind) = operation.get("op").and_then(Value::as_str) else {
11824                return Err(invalid_feed("proposal upload operation has no kind"));
11825            };
11826            let hash = match kind {
11827                "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
11828                "asset_put" | "asset_resume" => operation
11829                    .get("asset")
11830                    .and_then(|asset| asset.get("blob_sha256"))
11831                    .and_then(Value::as_str),
11832                _ => None,
11833            };
11834            let Some(hash) = hash else { continue };
11835            let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
11836            if kind == "rename" {
11837                for field in ["from", "to"] {
11838                    coordinates.insert(
11839                        operation
11840                            .get(field)
11841                            .and_then(Value::as_str)
11842                            .ok_or_else(|| invalid_feed("proposal rename has no coordinate"))?
11843                            .to_string(),
11844                    );
11845                }
11846            } else {
11847                let path = operation
11848                    .get("path")
11849                    .and_then(Value::as_str)
11850                    .ok_or_else(|| invalid_feed("proposal upload has no coordinate"))?;
11851                coordinates.insert(if kind.starts_with("asset_") {
11852                    format!("assets/{path}")
11853                } else {
11854                    path.to_string()
11855                });
11856            }
11857        }
11858        let declarations = downloaded
11859            .iter()
11860            .map(|(sha256, bytes)| {
11861                json!({
11862                    "sha256": sha256,
11863                    "bytes": bytes.len(),
11864                    "coordinates": coordinates_by_hash
11865                        .get(sha256)
11866                        .into_iter()
11867                        .flatten()
11868                        .collect::<Vec<_>>(),
11869                })
11870            })
11871            .collect::<Vec<_>>();
11872        let mut items: Vec<Value> = Vec::with_capacity(downloaded.len());
11873        for batch in batch_upload_declarations(declarations) {
11874            let reserved = reserve_upload_window(
11875                cfg,
11876                &format!("/api/hub/brains/{}/v2/uploads", head.brain_id),
11877                &json!({ "blobs": batch }),
11878                "prepare proposal blob transport",
11879            )?;
11880            let reserved_items = reserved
11881                .get("uploads")
11882                .and_then(Value::as_array)
11883                .ok_or_else(|| invalid_feed("proposal upload reservation has no items"))?;
11884            items.extend(reserved_items.iter().cloned());
11885        }
11886        if items.len() != downloaded.len() {
11887            return Err(invalid_feed("proposal upload reservation changed the set"));
11888        }
11889        let mut references = Vec::with_capacity(items.len());
11890        for item in items {
11891            let hash = item
11892                .get("sha256")
11893                .and_then(Value::as_str)
11894                .ok_or_else(|| invalid_feed("proposal upload reservation has no hash"))?;
11895            let bytes = downloaded
11896                .get(hash)
11897                .ok_or_else(|| invalid_feed("proposal upload reservation introduced a blob"))?;
11898            let reservation_id = item
11899                .get("reservation_id")
11900                .and_then(Value::as_str)
11901                .filter(|id| crate::ulid::is_ulid(id))
11902                .ok_or_else(|| invalid_feed("proposal upload reservation has no id"))?;
11903            let expected_coordinates = coordinates_by_hash
11904                .get(hash)
11905                .ok_or_else(|| invalid_feed("proposal upload has no coordinate binding"))?;
11906            let returned_coordinates = item
11907                .get("coordinates")
11908                .and_then(Value::as_array)
11909                .ok_or_else(|| invalid_feed("proposal upload reservation has no coordinates"))?;
11910            if returned_coordinates.len() != expected_coordinates.len()
11911                || returned_coordinates
11912                    .iter()
11913                    .zip(expected_coordinates)
11914                    .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
11915            {
11916                return Err(invalid_feed(
11917                    "proposal upload reservation changed its coordinates",
11918                ));
11919            }
11920            match item.get("status").and_then(Value::as_str) {
11921                Some("upload") => put_presigned(
11922                    cfg,
11923                    item.get("url")
11924                        .and_then(Value::as_str)
11925                        .ok_or_else(|| invalid_feed("proposal upload has no URL"))?,
11926                    item.get("headers").unwrap_or(&Value::Null),
11927                    bytes,
11928                )?,
11929                Some("already_present") => {}
11930                _ => return Err(invalid_feed("proposal upload status is invalid")),
11931            }
11932            references.push(json!({
11933                "sha256": hash,
11934                "bytes": bytes.len(),
11935                "reservation_id": reservation_id,
11936            }));
11937        }
11938        body["blobs"] = Value::Array(references);
11939    }
11940    // A proposal can be queued through the staged lane, so acceptance has to be
11941    // able to restate it. Capping only this side would make a large proposal
11942    // permanently unacceptable.
11943    stage_oversized_v2_change(cfg, &head.brain_id, &operations, &mut body)?;
11944    let path = format!("/api/hub/brains/{}/v2/commits", head.brain_id);
11945    let mut result = ensure_ok(
11946        request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
11947        "exact proposal acceptance",
11948    )?;
11949    let mut candidate_hub_signer = None;
11950    if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
11951        let request_id = result
11952            .get("request_id")
11953            .and_then(Value::as_str)
11954            .ok_or_else(|| invalid_feed("proposal acceptance has no request id"))?
11955            .to_string();
11956        let challenge = result
11957            .get("signing_challenge")
11958            .ok_or_else(|| invalid_feed("proposal acceptance has no signing challenge"))?;
11959        let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
11960            cfg,
11961            &head,
11962            &expected_candidate,
11963            &expected_candidate_assets,
11964            mutation_id,
11965            &v2_signed_request_view(&body, &operations),
11966            challenge,
11967        )?;
11968        body["signing_challenge_id"] = Value::String(challenge_id);
11969        body["signature_base64url"] = Value::String(signature);
11970        candidate_hub_signer = Some(actor_signer);
11971        result = ensure_ok(
11972            request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
11973            "signed exact proposal acceptance",
11974        )?;
11975    }
11976    let refreshed = v2_verified_head(cfg, brain)?
11977        .ok_or_else(|| invalid_feed("v2 head disappeared after proposal acceptance"))?;
11978    if candidate_hub_signer
11979        .as_ref()
11980        .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
11981        || refreshed
11982            .pointer
11983            .as_ref()
11984            .map(|pointer| pointer.commit_hash.as_str())
11985            != result.get("commit_hash").and_then(Value::as_str)
11986        || result.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
11987        || result.get("proposal_state").and_then(Value::as_str) != Some("accepted")
11988    {
11989        return Err(LinkError::RemoteAdvancedDuringSync);
11990    }
11991    accept_v2_head(cfg, &refreshed)?;
11992    Ok(result)
11993}
11994
11995// ─────────────────────────────────────────────────────────────────────────────
11996// propose — write without trust: evidence into the owner's inbox
11997// ─────────────────────────────────────────────────────────────────────────────
11998
11999/// Submit `body` to the published site `handle`, addressed to its app page
12000/// `app` (a page that declares the `write-inbox` capability). Deliberately
12001/// unauthenticated — this is the cross-party door; the submission lands as
12002/// *evidence* in the owner's `sources/inbox/`, never as truth, and the
12003/// owner's curator accepts or rejects it. Returns the hub's `{id, path}`
12004/// receipt.
12005pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
12006    require_valid_handle(handle)?;
12007    if body.len() as u64 > MAX_PROPOSE_BYTES {
12008        return Err(LinkError::ProposeTooLarge {
12009            bytes: body.len() as u64,
12010        });
12011    }
12012    let payload = json!({ "app": app, "body": body });
12013    // A ULID-shaped target is a bare brain address (link.md §7.4's
12014    // generalization): the brain inbox door, open on public brains, where a
12015    // configured credential earns a bigger actor-class budget. Anything else
12016    // is a published-site handle: that door is unauthenticated by design.
12017    let (path, auth) = if crate::ulid::is_ulid(handle) {
12018        (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
12019    } else {
12020        (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
12021    };
12022    ensure_ok(
12023        request(cfg, "POST", &path, Some(&payload), auth)?,
12024        "propose",
12025    )
12026}
12027
12028// ─────────────────────────────────────────────────────────────────────────────
12029// subscribe — follow feed-head movement
12030// ─────────────────────────────────────────────────────────────────────────────
12031
12032/// One observation of a brain's feed head.
12033#[derive(Debug, serde::Serialize)]
12034pub struct Head {
12035    /// The brain id.
12036    pub brain: String,
12037    /// The hub's durable feed cursor — advances on every accepted write.
12038    pub seq: u64,
12039    /// The hub's `updatedAt` for the brain, when present.
12040    #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
12041    pub updated_at: Option<String>,
12042    /// SHA-256 of the exact signed head entry.
12043    #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
12044    pub feed_hash: Option<String>,
12045    /// Whether the head entry's content hash, identity, and Ed25519 signature
12046    /// were verified locally. Path-scoped grants get head movement only.
12047    pub verified: bool,
12048}
12049
12050struct BoundedVecVisitor<T, const MAX: usize> {
12051    label: &'static str,
12052    marker: std::marker::PhantomData<T>,
12053}
12054
12055impl<'de, T, const MAX: usize> serde::de::Visitor<'de> for BoundedVecVisitor<T, MAX>
12056where
12057    T: Deserialize<'de>,
12058{
12059    type Value = Vec<T>;
12060
12061    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
12062        write!(formatter, "at most {MAX} {}", self.label)
12063    }
12064
12065    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
12066    where
12067        A: serde::de::SeqAccess<'de>,
12068    {
12069        if sequence.size_hint().is_some_and(|size| size > MAX) {
12070            return Err(serde::de::Error::custom(format!(
12071                "{} exceeds the {MAX}-item limit",
12072                self.label
12073            )));
12074        }
12075        let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX));
12076        while let Some(value) = sequence.next_element()? {
12077            if values.len() == MAX {
12078                return Err(serde::de::Error::custom(format!(
12079                    "{} exceeds the {MAX}-item limit",
12080                    self.label
12081                )));
12082            }
12083            values.push(value);
12084        }
12085        Ok(values)
12086    }
12087}
12088
12089fn deserialize_bounded_vec<'de, D, T, const MAX: usize>(
12090    deserializer: D,
12091    label: &'static str,
12092) -> Result<Vec<T>, D::Error>
12093where
12094    D: serde::Deserializer<'de>,
12095    T: Deserialize<'de>,
12096{
12097    deserializer.deserialize_seq(BoundedVecVisitor::<T, MAX> {
12098        label,
12099        marker: std::marker::PhantomData,
12100    })
12101}
12102
12103fn deserialize_feed_files<'de, D>(deserializer: D) -> Result<Vec<FeedFile>, D::Error>
12104where
12105    D: serde::Deserializer<'de>,
12106{
12107    deserialize_bounded_vec::<D, FeedFile, MAX_PUSH_FILES>(deserializer, "feed files")
12108}
12109
12110fn deserialize_removed_paths<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
12111where
12112    D: serde::Deserializer<'de>,
12113{
12114    deserialize_bounded_vec::<D, String, MAX_PUSH_FILES>(deserializer, "removed paths")
12115}
12116
12117fn deserialize_previous_identities<'de, D>(
12118    deserializer: D,
12119) -> Result<Vec<PreviousIdentity>, D::Error>
12120where
12121    D: serde::Deserializer<'de>,
12122{
12123    deserialize_bounded_vec::<D, PreviousIdentity, MAX_IDENTITY_ROTATIONS>(
12124        deserializer,
12125        "previous identities",
12126    )
12127}
12128
12129fn deserialize_rotations<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
12130where
12131    D: serde::Deserializer<'de>,
12132{
12133    deserialize_bounded_vec::<D, String, MAX_IDENTITY_ROTATIONS>(
12134        deserializer,
12135        "rotation statements",
12136    )
12137}
12138
12139fn deserialize_feed_items<'de, D>(deserializer: D) -> Result<Vec<FeedItem>, D::Error>
12140where
12141    D: serde::Deserializer<'de>,
12142{
12143    deserialize_bounded_vec::<D, FeedItem, FEED_PAGE_LIMIT>(deserializer, "feed entries")
12144}
12145
12146#[derive(Debug, Clone, Deserialize, Serialize)]
12147struct FeedFile {
12148    path: String,
12149    sha256: String,
12150    bytes: u64,
12151}
12152
12153#[cfg(test)]
12154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12155enum V1DisclosureError {
12156    DuplicateFile,
12157    DuplicateRemoved,
12158    PushManifestMismatch,
12159    EditMissingChange,
12160    EditFalseFile,
12161    RemovedMismatch,
12162}
12163
12164/// Validate wire-profile-v1 `files`/`removed` disclosure semantics against
12165/// the exact previous and resulting pack manifests. `edit` accepts the
12166/// historical superset form while still requiring every actual changed path.
12167#[cfg(test)]
12168fn verify_v1_manifest_disclosure(
12169    kind: &str,
12170    previous: &[FeedFile],
12171    resulting: &[FeedFile],
12172    files: &[FeedFile],
12173    removed: &[String],
12174) -> Result<(), V1DisclosureError> {
12175    fn as_map(
12176        files: &[FeedFile],
12177    ) -> Result<std::collections::BTreeMap<&str, (&str, u64)>, V1DisclosureError> {
12178        let mut result = std::collections::BTreeMap::new();
12179        for file in files {
12180            if result
12181                .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
12182                .is_some()
12183            {
12184                return Err(V1DisclosureError::DuplicateFile);
12185            }
12186        }
12187        Ok(result)
12188    }
12189    let previous = as_map(previous)?;
12190    let resulting = as_map(resulting)?;
12191    let disclosed = as_map(files)?;
12192    let removed_set: std::collections::BTreeSet<&str> =
12193        removed.iter().map(String::as_str).collect();
12194    if removed_set.len() != removed.len() {
12195        return Err(V1DisclosureError::DuplicateRemoved);
12196    }
12197    let expected_removed: std::collections::BTreeSet<&str> = previous
12198        .keys()
12199        .copied()
12200        .filter(|path| !resulting.contains_key(path))
12201        .collect();
12202    if removed_set != expected_removed {
12203        return Err(V1DisclosureError::RemovedMismatch);
12204    }
12205    if kind == "push" {
12206        return if disclosed == resulting {
12207            Ok(())
12208        } else {
12209            Err(V1DisclosureError::PushManifestMismatch)
12210        };
12211    }
12212    if kind != "edit" {
12213        return Err(V1DisclosureError::EditFalseFile);
12214    }
12215    if disclosed
12216        .iter()
12217        .any(|(path, value)| resulting.get(path) != Some(value))
12218    {
12219        return Err(V1DisclosureError::EditFalseFile);
12220    }
12221    for (path, value) in &resulting {
12222        if previous.get(path) != Some(value) && !disclosed.contains_key(path) {
12223            return Err(V1DisclosureError::EditMissingChange);
12224        }
12225    }
12226    Ok(())
12227}
12228
12229#[derive(Debug, Clone, Deserialize, Serialize)]
12230struct FeedEntry {
12231    v: u8,
12232    seq: u64,
12233    ts: String,
12234    brain: String,
12235    public_key: String,
12236    kind: String,
12237    op: String,
12238    pack_sha256: String,
12239    #[serde(deserialize_with = "deserialize_feed_files")]
12240    files: Vec<FeedFile>,
12241    #[serde(deserialize_with = "deserialize_removed_paths")]
12242    removed: Vec<String>,
12243    prev_entry_hash: Option<String>,
12244    sig: String,
12245}
12246
12247#[derive(Serialize)]
12248struct UnsignedFeedEntry<'a> {
12249    v: u8,
12250    seq: u64,
12251    ts: &'a str,
12252    brain: &'a str,
12253    public_key: &'a str,
12254    kind: &'a str,
12255    op: &'a str,
12256    pack_sha256: &'a str,
12257    files: &'a [FeedFile],
12258    removed: &'a [String],
12259    prev_entry_hash: &'a Option<String>,
12260}
12261
12262#[derive(Debug, Clone, Deserialize, Serialize)]
12263struct FeedItem {
12264    hash: String,
12265    entry: FeedEntry,
12266}
12267
12268#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
12269struct FeedIdentity {
12270    fingerprint: String,
12271    #[serde(rename = "publicKeySpki")]
12272    public_key_spki: String,
12273    /// Rotation history (link.md §9.1): identities this brain previously
12274    /// signed as. Entries verify against current OR previous — rotation
12275    /// never invalidates history.
12276    #[serde(default, deserialize_with = "deserialize_previous_identities")]
12277    previous: Vec<PreviousIdentity>,
12278    /// Exact normative rotation statements, oldest first. A list of previous
12279    /// public keys without these old-key signatures is not a trust chain.
12280    #[serde(default, deserialize_with = "deserialize_rotations")]
12281    rotations: Vec<String>,
12282}
12283
12284#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
12285struct PreviousIdentity {
12286    fingerprint: String,
12287    #[serde(rename = "publicKeySpki")]
12288    public_key_spki: String,
12289}
12290
12291#[derive(Debug, Deserialize)]
12292struct FeedResponse {
12293    #[serde(rename = "headSeq")]
12294    head_seq: u64,
12295    #[serde(rename = "feedHash")]
12296    feed_hash: Option<String>,
12297    identity: Option<FeedIdentity>,
12298    #[serde(deserialize_with = "deserialize_feed_items")]
12299    entries: Vec<FeedItem>,
12300    #[serde(rename = "scopeLimited")]
12301    scope_limited: bool,
12302}
12303
12304#[derive(Debug, Deserialize, Serialize)]
12305#[serde(deny_unknown_fields)]
12306struct RotationStatement {
12307    v: u8,
12308    op: String,
12309    brain: String,
12310    public_key: String,
12311    new_brain: String,
12312    new_public_key: String,
12313    prior_head_seq: u64,
12314    prior_feed_hash: Option<String>,
12315    ts: String,
12316    sig: String,
12317}
12318
12319#[derive(Debug, Clone, Deserialize, Serialize)]
12320struct TrustState {
12321    v: u8,
12322    origin: String,
12323    /// The exact caller-visible ref whose resolution this checkpoint pins.
12324    /// Slugs/handles are mutable names at the hub; once observed, they may not
12325    /// silently resolve to a different canonical brain.
12326    #[serde(default)]
12327    requested: String,
12328    /// Canonical hub brain id returned for `requested`.
12329    brain: String,
12330    /// Federation home origin when this ref was learned from a registry.
12331    /// Once observed, the registry cannot silently relocate the same handle.
12332    #[serde(default, skip_serializing_if = "Option::is_none")]
12333    home: Option<String>,
12334    anchor: String,
12335    current: String,
12336    #[serde(rename = "headSeq")]
12337    head_seq: u64,
12338    #[serde(rename = "feedHash")]
12339    feed_hash: Option<String>,
12340    /// Exact accepted old-key-signed rotation statements, oldest first. v2
12341    /// checkpoints require this vector to be an immutable prefix of every
12342    /// subsequently served identity chain.
12343    #[serde(default)]
12344    rotations: Vec<String>,
12345    /// Hub pointer-signing identity pinned on first v2 observation. This is
12346    /// separate from the brain key, which signs commits.
12347    #[serde(default, skip_serializing_if = "Option::is_none")]
12348    hub_signer: Option<String>,
12349    /// Explicit transport profile. Older accepted non-empty v2 checkpoints
12350    /// are recognized by their pinned hub signer during one-way migration.
12351    #[serde(default, skip_serializing_if = "Option::is_none")]
12352    protocol_profile: Option<String>,
12353}
12354
12355fn accepted_as_v2(state: &TrustState) -> bool {
12356    state.protocol_profile.as_deref() == Some("link-v2") || state.hub_signer.is_some()
12357}
12358
12359fn has_accepted_v2_ref(cfg: &HubConfig, requested: &str) -> LinkResult<bool> {
12360    let directory = open_trust_dir(cfg)?;
12361    if load_trust_in(cfg, &directory, requested)?.is_some_and(|state| accepted_as_v2(&state)) {
12362        return Ok(true);
12363    }
12364    let Some(alias) = load_alias_in(cfg, &directory, requested)? else {
12365        return Ok(false);
12366    };
12367    Ok(load_trust_in(cfg, &directory, &alias.brain)?.is_some_and(|state| accepted_as_v2(&state)))
12368}
12369
12370#[derive(Debug, Clone, Deserialize, Serialize)]
12371struct AliasBinding {
12372    v: u8,
12373    origin: String,
12374    requested: String,
12375    brain: String,
12376    #[serde(default, skip_serializing_if = "Option::is_none")]
12377    home: Option<String>,
12378}
12379
12380struct VerifiedRemote {
12381    head: Head,
12382    identity: Option<FeedIdentity>,
12383    head_entry: Option<FeedItem>,
12384    /// Present when the caller requested and verified the complete chain.
12385    entries: Vec<FeedItem>,
12386    anchor: Option<String>,
12387}
12388
12389fn invalid_feed(message: impl Into<String>) -> LinkError {
12390    LinkError::InvalidFeed {
12391        message: message.into(),
12392    }
12393}
12394
12395fn is_sha256(value: &str) -> bool {
12396    value.len() == 64
12397        && value
12398            .bytes()
12399            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
12400}
12401
12402fn identity_fingerprint(public_key_spki: &str) -> LinkResult<String> {
12403    let der = URL_SAFE_NO_PAD
12404        .decode(public_key_spki)
12405        .map_err(|_| invalid_feed("identity public key is not base64url"))?;
12406    if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
12407        return Err(invalid_feed(
12408            "identity public key is not a valid Ed25519 SPKI",
12409        ));
12410    }
12411    Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(&der)))
12412}
12413
12414/// Verify the hub's current identity and every old-key-signed rotation that
12415/// leads from the original TOFU anchor to it. `previous` alone is metadata,
12416/// never authority.
12417fn verify_identity_chain(
12418    identity: &FeedIdentity,
12419    pinned: Option<&TrustState>,
12420) -> LinkResult<String> {
12421    if identity.previous.len() > MAX_IDENTITY_ROTATIONS
12422        || identity.rotations.len() > MAX_IDENTITY_ROTATIONS
12423    {
12424        return Err(invalid_feed(
12425            "identity rotation history exceeds the client cap",
12426        ));
12427    }
12428    if identity.fingerprint != identity_fingerprint(&identity.public_key_spki)? {
12429        return Err(invalid_feed(
12430            "current identity fingerprint does not match its public key",
12431        ));
12432    }
12433    for previous in &identity.previous {
12434        if previous.fingerprint != identity_fingerprint(&previous.public_key_spki)? {
12435            return Err(invalid_feed(
12436                "previous identity fingerprint does not match its public key",
12437            ));
12438        }
12439    }
12440    if identity.rotations.len() != identity.previous.len() {
12441        return Err(invalid_feed(
12442            "identity history is missing an old-key-signed rotation statement",
12443        ));
12444    }
12445
12446    // The HTTP identity lists prior identities newest first. Rotation
12447    // statements are chronological, so verification walks the reversed list
12448    // and ends at the current identity.
12449    let mut chain: Vec<(&str, &str)> = identity
12450        .previous
12451        .iter()
12452        .rev()
12453        .map(|p| (p.fingerprint.as_str(), p.public_key_spki.as_str()))
12454        .collect();
12455    chain.push((&identity.fingerprint, &identity.public_key_spki));
12456
12457    for (index, raw) in identity.rotations.iter().enumerate() {
12458        let statement: RotationStatement = serde_json::from_str(raw)
12459            .map_err(|_| invalid_feed("rotation statement did not parse exactly"))?;
12460        let (old_fingerprint, old_spki) = chain[index];
12461        let (new_fingerprint, new_spki) = chain[index + 1];
12462        if statement.v != 1
12463            || statement.op != "rotate"
12464            || statement.brain != format!("ed25519:{old_fingerprint}")
12465            || statement.public_key != old_spki
12466            || statement.new_brain != format!("ed25519:{new_fingerprint}")
12467            || statement.new_public_key != new_spki
12468            || (statement.prior_head_seq == 0 && statement.prior_feed_hash.is_some())
12469            || (statement.prior_head_seq > 0
12470                && statement
12471                    .prior_feed_hash
12472                    .as_deref()
12473                    .is_none_or(|hash| !is_sha256(hash)))
12474        {
12475            return Err(invalid_feed(
12476                "rotation statement does not connect adjacent identities",
12477            ));
12478        }
12479        let unsigned = serde_json::to_string(&UnsignedRotation {
12480            v: statement.v,
12481            op: &statement.op,
12482            brain: &statement.brain,
12483            public_key: &statement.public_key,
12484            new_brain: &statement.new_brain,
12485            new_public_key: &statement.new_public_key,
12486            prior_head_seq: statement.prior_head_seq,
12487            prior_feed_hash: statement.prior_feed_hash.as_deref(),
12488            ts: statement.ts.clone(),
12489        })
12490        .map_err(|_| invalid_feed("could not canonicalize rotation statement"))?;
12491        let exact = format!(
12492            "{},\"sig\":\"{}\"}}",
12493            &unsigned[..unsigned.len() - 1],
12494            statement.sig
12495        );
12496        if exact != *raw {
12497            return Err(invalid_feed(
12498                "rotation statement is not in normative serialization",
12499            ));
12500        }
12501        let der = URL_SAFE_NO_PAD
12502            .decode(old_spki)
12503            .map_err(|_| invalid_feed("rotation public key is not base64url"))?;
12504        let signature = URL_SAFE_NO_PAD
12505            .decode(&statement.sig)
12506            .map_err(|_| invalid_feed("rotation signature is not base64url"))?;
12507        UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
12508            .verify(unsigned.as_bytes(), &signature)
12509            .map_err(|_| invalid_feed("rotation signature verification failed"))?;
12510        if index > 0 {
12511            let prior: RotationStatement = serde_json::from_str(&identity.rotations[index - 1])
12512                .map_err(|_| invalid_feed("prior rotation statement did not parse"))?;
12513            if statement.prior_head_seq < prior.prior_head_seq {
12514                return Err(invalid_feed("rotation feed boundaries move backward"));
12515            }
12516        }
12517    }
12518
12519    let anchor = format!("ed25519:{}", chain[0].0);
12520    let current = format!("ed25519:{}", identity.fingerprint);
12521    if let Some(pin) = pinned {
12522        if pin.anchor != anchor {
12523            return Err(invalid_feed(
12524                "served identity chain does not descend from the pinned anchor",
12525            ));
12526        }
12527        if !chain
12528            .iter()
12529            .any(|(fingerprint, _)| pin.current == format!("ed25519:{fingerprint}"))
12530        {
12531            return Err(invalid_feed(
12532                "served identity chain forked away from the last pinned identity",
12533            ));
12534        }
12535        if pin.current == current && pin.anchor != current && identity.rotations.is_empty() {
12536            return Err(invalid_feed("served identity discarded its rotation chain"));
12537        }
12538        if pin.v >= 2
12539            && (identity.rotations.len() < pin.rotations.len()
12540                || identity.rotations[..pin.rotations.len()] != pin.rotations)
12541        {
12542            return Err(invalid_feed(
12543                "served identity rewrote the locally accepted rotation history",
12544            ));
12545        }
12546    }
12547    Ok(anchor)
12548}
12549
12550fn verify_rotation_feed_boundaries(
12551    identity: &FeedIdentity,
12552    pinned: Option<&TrustState>,
12553    observed: &[FeedItem],
12554    advertised_seq: u64,
12555) -> LinkResult<()> {
12556    let mut chain: Vec<String> = identity
12557        .previous
12558        .iter()
12559        .rev()
12560        .map(|previous| format!("ed25519:{}", previous.fingerprint))
12561        .collect();
12562    chain.push(format!("ed25519:{}", identity.fingerprint));
12563    let pinned_index = pinned.and_then(|pin| chain.iter().position(|key| key == &pin.current));
12564
12565    for (index, raw) in identity.rotations.iter().enumerate() {
12566        let rotation: RotationStatement = serde_json::from_str(raw)
12567            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
12568        if rotation.prior_head_seq > advertised_seq {
12569            return Err(invalid_feed(
12570                "rotation claims a feed boundary beyond the advertised head",
12571            ));
12572        }
12573        if let (Some(pin), Some(pin_index)) = (pinned, pinned_index) {
12574            if index >= pin_index && rotation.prior_head_seq < pin.head_seq {
12575                return Err(invalid_feed(
12576                    "newly disclosed rotation predates the local feed checkpoint",
12577                ));
12578            }
12579        }
12580        let actual = if rotation.prior_head_seq == 0 {
12581            None
12582        } else if pinned.is_some_and(|pin| pin.head_seq == rotation.prior_head_seq) {
12583            pinned.and_then(|pin| pin.feed_hash.as_deref())
12584        } else {
12585            observed
12586                .iter()
12587                .find(|item| item.entry.seq == rotation.prior_head_seq)
12588                .map(|item| item.hash.as_str())
12589        };
12590        if let Some(actual) = actual {
12591            if rotation.prior_feed_hash.as_deref() != Some(actual) {
12592                return Err(invalid_feed(
12593                    "rotation statement does not commit the verified feed boundary",
12594                ));
12595            }
12596        } else if rotation.prior_head_seq == 0 {
12597            // The empty-feed boundary is represented by (0, null); there is
12598            // no entry hash to find in `observed`.
12599        } else if pinned.is_some_and(|pin| {
12600            pinned_index.is_some_and(|pin_index| index >= pin_index)
12601                || rotation.prior_head_seq >= pin.head_seq
12602        }) {
12603            return Err(invalid_feed(
12604                "rotation feed boundary was not present in the verified chain",
12605            ));
12606        }
12607    }
12608    Ok(())
12609}
12610
12611/// Once a client has checkpointed identity K, identities retired before K may
12612/// verify old history but can never regain authority over a later sequence.
12613/// This is also the safe migration rule for legacy v1 checkpoints that did not
12614/// persist the exact accepted rotation statements.
12615fn reject_retired_signer_after_checkpoint(
12616    identity: &FeedIdentity,
12617    pinned: Option<&TrustState>,
12618    item: &FeedItem,
12619) -> LinkResult<()> {
12620    let Some(pin) = pinned else {
12621        return Ok(());
12622    };
12623    if item.entry.seq <= pin.head_seq {
12624        return Ok(());
12625    }
12626    let mut chain: Vec<String> = identity
12627        .previous
12628        .iter()
12629        .rev()
12630        .map(|previous| format!("ed25519:{}", previous.fingerprint))
12631        .collect();
12632    chain.push(format!("ed25519:{}", identity.fingerprint));
12633    let pinned_index = chain
12634        .iter()
12635        .position(|key| key == &pin.current)
12636        .ok_or_else(|| invalid_feed("pinned identity is absent from the served chain"))?;
12637    let signer_index = chain
12638        .iter()
12639        .position(|key| key == &item.entry.brain)
12640        .ok_or_else(|| invalid_feed("feed signer is absent from the served identity chain"))?;
12641    if signer_index < pinned_index {
12642        return Err(invalid_feed(
12643            "a retired identity attempted to sign after the local checkpoint",
12644        ));
12645    }
12646    Ok(())
12647}
12648
12649fn trust_file_name(cfg: &HubConfig, brain: &str) -> LinkResult<String> {
12650    let origin = normalized_origin(&cfg.hub)?;
12651    let key = format!(
12652        "{:x}",
12653        Sha256::digest(format!("{origin}\0{brain}").as_bytes())
12654    );
12655    Ok(format!("{key}.json"))
12656}
12657
12658fn alias_file_name(cfg: &HubConfig, alias: &str) -> LinkResult<String> {
12659    let origin = normalized_origin(&cfg.hub)?;
12660    let key = format!(
12661        "{:x}",
12662        Sha256::digest(format!("{origin}\0alias\0{alias}").as_bytes())
12663    );
12664    Ok(format!("alias-{key}.json"))
12665}
12666
12667#[cfg(any(unix, windows))]
12668struct TrustLock {
12669    _file: std::fs::File,
12670}
12671
12672#[cfg(unix)]
12673fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
12674    use std::os::fd::{AsRawFd as _, FromRawFd as _};
12675
12676    let lock_string = format!(".{state_name}.lock");
12677    let lock_name = c_name(lock_string.as_bytes(), &lock_string)?;
12678    let fd = unsafe {
12679        libc::openat(
12680            directory.as_raw_fd(),
12681            lock_name.as_ptr(),
12682            libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12683            0o600,
12684        )
12685    };
12686    if fd < 0 {
12687        return Err(std::io::Error::last_os_error().into());
12688    }
12689    let file = unsafe { std::fs::File::from_raw_fd(fd) };
12690    if !file.metadata()?.is_file() {
12691        return Err(LinkError::UnsafePath { path: lock_string });
12692    }
12693    if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
12694        return Err(std::io::Error::last_os_error().into());
12695    }
12696    Ok(TrustLock { _file: file })
12697}
12698
12699#[cfg(windows)]
12700fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
12701    let lock_name = format!(".{state_name}.lock");
12702    let file = crate::fsx::lock_exclusive_beneath(directory, Path::new(&lock_name))?;
12703    Ok(TrustLock { _file: file })
12704}
12705
12706#[cfg(any(unix, windows))]
12707fn lock_trust_many(
12708    cfg: &HubConfig,
12709    directory: &std::fs::File,
12710    refs: &[&str],
12711) -> LinkResult<Vec<TrustLock>> {
12712    let mut names = refs
12713        .iter()
12714        .map(|reference| trust_file_name(cfg, reference))
12715        .collect::<LinkResult<Vec<_>>>()?;
12716    names.sort();
12717    names.dedup();
12718    names
12719        .iter()
12720        .map(|name| lock_trust_name(directory, name))
12721        .collect()
12722}
12723
12724#[cfg(not(any(unix, windows)))]
12725fn lock_trust_many(
12726    _cfg: &HubConfig,
12727    _directory: &TrustDirectory,
12728    _refs: &[&str],
12729) -> LinkResult<Vec<()>> {
12730    Err(LinkError::UnsupportedPlatform {
12731        operation: "verified link.md state",
12732    })
12733}
12734
12735#[cfg(any(unix, windows))]
12736type TrustDirectory = std::fs::File;
12737
12738#[cfg(not(any(unix, windows)))]
12739struct TrustDirectory;
12740
12741#[cfg(unix)]
12742fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
12743    use std::os::fd::AsRawFd as _;
12744
12745    let directory = open_or_create_dir_nofollow(&cfg.state_dir.join("trust"))?;
12746    if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
12747        return Err(std::io::Error::last_os_error().into());
12748    }
12749    directory.sync_all()?;
12750    Ok(directory)
12751}
12752
12753#[cfg(windows)]
12754fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
12755    let marker = cfg.state_dir.join("trust").join(".directory");
12756    crate::fsx::write_atomic(&marker, b"link.md trust directory\n")?;
12757    Ok(crate::fsx::open_directory_nofollow(
12758        marker.parent().expect("trust marker has a parent"),
12759    )?)
12760}
12761
12762#[cfg(not(any(unix, windows)))]
12763fn open_trust_dir(_cfg: &HubConfig) -> LinkResult<TrustDirectory> {
12764    Err(LinkError::UnsupportedPlatform {
12765        operation: "verified link.md state",
12766    })
12767}
12768
12769#[cfg(unix)]
12770fn load_trust_in(
12771    cfg: &HubConfig,
12772    directory: &TrustDirectory,
12773    requested: &str,
12774) -> LinkResult<Option<TrustState>> {
12775    use std::os::fd::{AsRawFd as _, FromRawFd as _};
12776
12777    let name_string = trust_file_name(cfg, requested)?;
12778    let name = c_name(name_string.as_bytes(), &name_string)?;
12779    let fd = unsafe {
12780        libc::openat(
12781            directory.as_raw_fd(),
12782            name.as_ptr(),
12783            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12784        )
12785    };
12786    if fd < 0 {
12787        let error = std::io::Error::last_os_error();
12788        if error.kind() == std::io::ErrorKind::NotFound {
12789            return Ok(None);
12790        }
12791        return Err(LinkError::UnsafePath { path: name_string });
12792    }
12793    let file = unsafe { std::fs::File::from_raw_fd(fd) };
12794    if !file.metadata()?.is_file() {
12795        return Err(LinkError::UnsafePath { path: name_string });
12796    }
12797    let mut bytes = Vec::new();
12798    file.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
12799    if bytes.len() > 1024 * 1024 {
12800        return Err(invalid_feed("local identity/feed checkpoint is oversized"));
12801    }
12802    let mut state: TrustState = serde_json::from_slice(&bytes)
12803        .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
12804    if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
12805        return Err(invalid_feed(
12806            "local identity/feed checkpoint does not match this hub and brain",
12807        ));
12808    }
12809    if state.v == 1 {
12810        // Legacy files were keyed by canonical brain id. They can migrate only
12811        // when the caller used that exact id; old slug lookups had no durable
12812        // alias binding and therefore cannot be guessed safely.
12813        if state.brain != requested {
12814            return Err(invalid_feed(
12815                "legacy checkpoint is not bound to the requested brain id",
12816            ));
12817        }
12818        state.requested = requested.to_string();
12819    } else if state.requested != requested {
12820        return Err(invalid_feed(
12821            "local identity/feed checkpoint is bound to a different requested ref",
12822        ));
12823    }
12824    Ok(Some(state))
12825}
12826
12827#[cfg(windows)]
12828fn load_trust_in(
12829    cfg: &HubConfig,
12830    directory: &TrustDirectory,
12831    requested: &str,
12832) -> LinkResult<Option<TrustState>> {
12833    let name = trust_file_name(cfg, requested)?;
12834    let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
12835    let bytes = match reader.read(Path::new(&name), 1024 * 1024) {
12836        Ok(bytes) => bytes,
12837        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
12838        Err(_) => return Err(LinkError::UnsafePath { path: name }),
12839    };
12840    let mut state: TrustState = serde_json::from_slice(&bytes)
12841        .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
12842    if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
12843        return Err(invalid_feed(
12844            "local identity/feed checkpoint does not match this hub and brain",
12845        ));
12846    }
12847    if state.v == 1 {
12848        if state.brain != requested {
12849            return Err(invalid_feed(
12850                "legacy checkpoint is not bound to the requested brain id",
12851            ));
12852        }
12853        state.requested = requested.to_string();
12854    } else if state.requested != requested {
12855        return Err(invalid_feed(
12856            "local identity/feed checkpoint is bound to a different requested ref",
12857        ));
12858    }
12859    Ok(Some(state))
12860}
12861
12862#[cfg(not(any(unix, windows)))]
12863fn load_trust_in(
12864    _cfg: &HubConfig,
12865    _directory: &TrustDirectory,
12866    _brain: &str,
12867) -> LinkResult<Option<TrustState>> {
12868    Err(LinkError::UnsupportedPlatform {
12869        operation: "verified link.md state",
12870    })
12871}
12872
12873#[cfg(all(test, any(unix, windows)))]
12874fn load_trust(cfg: &HubConfig, requested: &str) -> LinkResult<Option<TrustState>> {
12875    let directory = open_trust_dir(cfg)?;
12876    load_trust_in(cfg, &directory, requested)
12877}
12878
12879#[cfg(unix)]
12880fn save_trust_in(
12881    cfg: &HubConfig,
12882    directory: &TrustDirectory,
12883    state: &TrustState,
12884) -> LinkResult<()> {
12885    use std::os::fd::{AsRawFd as _, FromRawFd as _};
12886
12887    let name_string = trust_file_name(cfg, &state.requested)?;
12888    let name = c_name(name_string.as_bytes(), &name_string)?;
12889    let mut bytes = serde_json::to_vec(state)
12890        .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
12891    bytes.push(b'\n');
12892
12893    let nonce = std::time::SystemTime::now()
12894        .duration_since(std::time::UNIX_EPOCH)
12895        .unwrap_or_default()
12896        .as_nanos();
12897    let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
12898    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
12899    let fd = unsafe {
12900        libc::openat(
12901            directory.as_raw_fd(),
12902            temp.as_ptr(),
12903            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12904            0o600,
12905        )
12906    };
12907    if fd < 0 {
12908        return Err(std::io::Error::last_os_error().into());
12909    }
12910    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
12911    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
12912        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
12913        return Err(error.into());
12914    }
12915    drop(file);
12916    if unsafe {
12917        libc::renameat(
12918            directory.as_raw_fd(),
12919            temp.as_ptr(),
12920            directory.as_raw_fd(),
12921            name.as_ptr(),
12922        )
12923    } != 0
12924    {
12925        let error = std::io::Error::last_os_error();
12926        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
12927        return Err(error.into());
12928    }
12929    directory.sync_all()?;
12930    Ok(())
12931}
12932
12933#[cfg(windows)]
12934fn save_trust_in(
12935    cfg: &HubConfig,
12936    directory: &TrustDirectory,
12937    state: &TrustState,
12938) -> LinkResult<()> {
12939    let name = trust_file_name(cfg, &state.requested)?;
12940    let mut bytes = serde_json::to_vec(state)
12941        .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
12942    bytes.push(b'\n');
12943    crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
12944    Ok(())
12945}
12946
12947#[cfg(not(any(unix, windows)))]
12948fn save_trust_in(
12949    _cfg: &HubConfig,
12950    _directory: &TrustDirectory,
12951    _state: &TrustState,
12952) -> LinkResult<()> {
12953    Err(LinkError::UnsupportedPlatform {
12954        operation: "verified link.md state",
12955    })
12956}
12957
12958#[cfg(unix)]
12959fn load_alias_in(
12960    cfg: &HubConfig,
12961    directory: &TrustDirectory,
12962    requested: &str,
12963) -> LinkResult<Option<AliasBinding>> {
12964    use std::os::fd::{AsRawFd as _, FromRawFd as _};
12965
12966    let name_string = alias_file_name(cfg, requested)?;
12967    let name = c_name(name_string.as_bytes(), &name_string)?;
12968    let fd = unsafe {
12969        libc::openat(
12970            directory.as_raw_fd(),
12971            name.as_ptr(),
12972            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12973        )
12974    };
12975    if fd < 0 {
12976        let error = std::io::Error::last_os_error();
12977        if error.kind() == std::io::ErrorKind::NotFound {
12978            return Ok(None);
12979        }
12980        return Err(LinkError::UnsafePath { path: name_string });
12981    }
12982    let file = unsafe { std::fs::File::from_raw_fd(fd) };
12983    if !file.metadata()?.is_file() {
12984        return Err(LinkError::UnsafePath { path: name_string });
12985    }
12986    let mut bytes = Vec::new();
12987    file.take(64 * 1024 + 1).read_to_end(&mut bytes)?;
12988    if bytes.len() > 64 * 1024 {
12989        return Err(invalid_feed("local alias binding is oversized"));
12990    }
12991    let alias: AliasBinding = serde_json::from_slice(&bytes)
12992        .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
12993    if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
12994    {
12995        return Err(invalid_feed(
12996            "local alias binding does not match this hub and requested ref",
12997        ));
12998    }
12999    Ok(Some(alias))
13000}
13001
13002#[cfg(windows)]
13003fn load_alias_in(
13004    cfg: &HubConfig,
13005    directory: &TrustDirectory,
13006    requested: &str,
13007) -> LinkResult<Option<AliasBinding>> {
13008    let name = alias_file_name(cfg, requested)?;
13009    let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
13010    let bytes = match reader.read(Path::new(&name), 64 * 1024) {
13011        Ok(bytes) => bytes,
13012        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
13013        Err(_) => return Err(LinkError::UnsafePath { path: name }),
13014    };
13015    let alias: AliasBinding = serde_json::from_slice(&bytes)
13016        .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
13017    if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
13018    {
13019        return Err(invalid_feed(
13020            "local alias binding does not match this hub and requested ref",
13021        ));
13022    }
13023    Ok(Some(alias))
13024}
13025
13026#[cfg(not(any(unix, windows)))]
13027fn load_alias_in(
13028    _cfg: &HubConfig,
13029    _directory: &TrustDirectory,
13030    _requested: &str,
13031) -> LinkResult<Option<AliasBinding>> {
13032    Err(LinkError::UnsupportedPlatform {
13033        operation: "verified link.md state",
13034    })
13035}
13036
13037#[cfg(unix)]
13038fn save_alias_in(
13039    cfg: &HubConfig,
13040    directory: &TrustDirectory,
13041    alias: &AliasBinding,
13042) -> LinkResult<()> {
13043    use std::os::fd::{AsRawFd as _, FromRawFd as _};
13044
13045    let name_string = alias_file_name(cfg, &alias.requested)?;
13046    let name = c_name(name_string.as_bytes(), &name_string)?;
13047    let mut bytes = serde_json::to_vec(alias)
13048        .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
13049    bytes.push(b'\n');
13050    let nonce = std::time::SystemTime::now()
13051        .duration_since(std::time::UNIX_EPOCH)
13052        .unwrap_or_default()
13053        .as_nanos();
13054    let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
13055    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
13056    let fd = unsafe {
13057        libc::openat(
13058            directory.as_raw_fd(),
13059            temp.as_ptr(),
13060            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13061            0o600,
13062        )
13063    };
13064    if fd < 0 {
13065        return Err(std::io::Error::last_os_error().into());
13066    }
13067    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
13068    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
13069        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13070        return Err(error.into());
13071    }
13072    drop(file);
13073    if unsafe {
13074        libc::renameat(
13075            directory.as_raw_fd(),
13076            temp.as_ptr(),
13077            directory.as_raw_fd(),
13078            name.as_ptr(),
13079        )
13080    } != 0
13081    {
13082        let error = std::io::Error::last_os_error();
13083        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13084        return Err(error.into());
13085    }
13086    directory.sync_all()?;
13087    Ok(())
13088}
13089
13090#[cfg(windows)]
13091fn save_alias_in(
13092    cfg: &HubConfig,
13093    directory: &TrustDirectory,
13094    alias: &AliasBinding,
13095) -> LinkResult<()> {
13096    let name = alias_file_name(cfg, &alias.requested)?;
13097    let mut bytes = serde_json::to_vec(alias)
13098        .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
13099    bytes.push(b'\n');
13100    crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
13101    Ok(())
13102}
13103
13104#[cfg(not(any(unix, windows)))]
13105fn save_alias_in(
13106    _cfg: &HubConfig,
13107    _directory: &TrustDirectory,
13108    _alias: &AliasBinding,
13109) -> LinkResult<()> {
13110    Err(LinkError::UnsupportedPlatform {
13111        operation: "verified link.md state",
13112    })
13113}
13114
13115/// Load the canonical checkpoint shared by every spelling of one brain and
13116/// the separate alias binding. This also performs the one-way migration from
13117/// pre-v3 checkpoints that stored a full trust state under the alias itself.
13118/// Callers hold both alias and canonical locks before entering.
13119fn load_canonical_pin(
13120    cfg: &HubConfig,
13121    directory: &TrustDirectory,
13122    requested: &str,
13123    resolved_brain: &str,
13124) -> LinkResult<(Option<TrustState>, Option<AliasBinding>)> {
13125    let mut canonical = load_trust_in(cfg, directory, resolved_brain)?;
13126    if requested == resolved_brain {
13127        return Ok((canonical, None));
13128    }
13129
13130    let mut alias = load_alias_in(cfg, directory, requested)?;
13131    if let Some(binding) = &alias {
13132        if binding.brain != resolved_brain {
13133            return Err(LinkError::AliasRebindRequired {
13134                alias: requested.to_string(),
13135                from: binding.brain.clone(),
13136                to: resolved_brain.to_string(),
13137            });
13138        }
13139        return Ok((canonical, alias));
13140    }
13141
13142    // A v2 build stored the complete checkpoint under the requested slug.
13143    // Promote it to the canonical ULID key before creating the lightweight
13144    // alias binding. Never silently merge two independently advanced states.
13145    if let Some(legacy) = load_trust_in(cfg, directory, requested)? {
13146        if legacy.brain != resolved_brain {
13147            return Err(invalid_feed(
13148                "legacy alias checkpoint names a different canonical brain",
13149            ));
13150        }
13151        if let Some(existing) = &canonical {
13152            if existing.brain != legacy.brain
13153                || existing.anchor != legacy.anchor
13154                || existing.current != legacy.current
13155                || existing.head_seq != legacy.head_seq
13156                || existing.feed_hash != legacy.feed_hash
13157                || existing.rotations != legacy.rotations
13158            {
13159                return Err(invalid_feed(
13160                    "legacy alias checkpoint conflicts with the canonical checkpoint",
13161                ));
13162            }
13163        } else {
13164            let mut promoted = legacy.clone();
13165            promoted.requested = resolved_brain.to_string();
13166            promoted.home = None;
13167            save_trust_in(cfg, directory, &promoted)?;
13168            canonical = Some(promoted);
13169        }
13170        alias = Some(AliasBinding {
13171            v: 1,
13172            origin: normalized_origin(&cfg.hub)?,
13173            requested: requested.to_string(),
13174            brain: resolved_brain.to_string(),
13175            home: legacy.home,
13176        });
13177        save_alias_in(cfg, directory, alias.as_ref().expect("alias just created"))?;
13178    }
13179    Ok((canonical, alias))
13180}
13181
13182/// Explicitly replace one mutable alias binding after the operator reviews the
13183/// exact deleted/re-created brain ids. Canonical checkpoints are never removed
13184/// or rewritten, so old history remains pinned and the new brain establishes
13185/// its own independently verified trust chain.
13186pub fn rebind_v2_alias(cfg: &HubConfig, alias: &str, from: &str, to: &str) -> LinkResult<Value> {
13187    require_hardened_filesystem("verified alias rebind")?;
13188    require_safe_ref(alias)?;
13189    require_safe_ref(from)?;
13190    require_safe_ref(to)?;
13191    if crate::ulid::is_ulid(alias)
13192        || !crate::ulid::is_ulid(from)
13193        || !crate::ulid::is_ulid(to)
13194        || from == to
13195    {
13196        return Err(LinkError::InvalidPack {
13197            message:
13198                "alias rebind requires one non-ULID alias and two different exact canonical ULIDs"
13199                    .to_string(),
13200        });
13201    }
13202
13203    let verified = v2_verified_head(cfg, to)?.ok_or_else(|| LinkError::InvalidPack {
13204        message: "the proposed replacement is not a readable link.md v2 brain".to_string(),
13205    })?;
13206    accept_v2_head(cfg, &verified)?;
13207
13208    let alias_response = ensure_ok(
13209        request(
13210            cfg,
13211            "GET",
13212            &format!("/api/hub/brains/{alias}/v2/head"),
13213            None,
13214            Auth::Required,
13215        )?,
13216        "resolve alias for explicit rebind",
13217    )?;
13218    let resolved: V2HeadResponse = serde_json::from_value(alias_response)
13219        .map_err(|_| invalid_feed("alias rebind response has an invalid shape"))?;
13220    if resolved.v != 2 || resolved.brain_id != to {
13221        return Err(LinkError::RemoteAdvancedDuringSync);
13222    }
13223
13224    let directory = open_trust_dir(cfg)?;
13225    let _locks = lock_trust_many(cfg, &directory, &[alias, from, to])?;
13226    let binding = load_alias_in(cfg, &directory, alias)?.ok_or_else(|| LinkError::InvalidPack {
13227        message: "the requested alias has no existing local binding to replace".to_string(),
13228    })?;
13229    if binding.brain != from {
13230        return Err(LinkError::AliasRebindRequired {
13231            alias: alias.to_string(),
13232            from: binding.brain,
13233            to: to.to_string(),
13234        });
13235    }
13236    save_alias_in(
13237        cfg,
13238        &directory,
13239        &AliasBinding {
13240            v: 1,
13241            origin: normalized_origin(&cfg.hub)?,
13242            requested: alias.to_string(),
13243            brain: to.to_string(),
13244            home: binding.home,
13245        },
13246    )?;
13247    Ok(json!({
13248        "v": 2,
13249        "alias": alias,
13250        "from": from,
13251        "to": to,
13252        "outcome": "alias_rebound",
13253    }))
13254}
13255
13256fn save_canonical_pin_and_alias(
13257    cfg: &HubConfig,
13258    directory: &TrustDirectory,
13259    requested: &str,
13260    resolved_brain: &str,
13261    mut state: TrustState,
13262    existing_alias: Option<&AliasBinding>,
13263) -> LinkResult<()> {
13264    state.requested = resolved_brain.to_string();
13265    state.brain = resolved_brain.to_string();
13266    state.home = None;
13267    save_trust_in(cfg, directory, &state)?;
13268    if requested != resolved_brain {
13269        save_alias_in(
13270            cfg,
13271            directory,
13272            &AliasBinding {
13273                v: 1,
13274                origin: normalized_origin(&cfg.hub)?,
13275                requested: requested.to_string(),
13276                brain: resolved_brain.to_string(),
13277                home: existing_alias.and_then(|alias| alias.home.clone()),
13278            },
13279        )?;
13280    }
13281    Ok(())
13282}
13283
13284fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
13285    const ED25519_SPKI_PREFIX: &[u8] = &[
13286        0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
13287    ];
13288    let entry = &item.entry;
13289    let public_der = URL_SAFE_NO_PAD
13290        .decode(&entry.public_key)
13291        .map_err(|_| invalid_feed("public key is not base64url"))?;
13292    if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
13293        || !public_der.starts_with(ED25519_SPKI_PREFIX)
13294    {
13295        return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
13296    }
13297    let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
13298    if entry.brain != format!("ed25519:{fingerprint}") {
13299        return Err(invalid_feed(
13300            "brain fingerprint does not match its public key",
13301        ));
13302    }
13303    // Verify the complete rotation authority before using any previous key.
13304    let _ = verify_identity_chain(identity, None)?;
13305    let mut chain: Vec<(&str, &str)> = identity
13306        .previous
13307        .iter()
13308        .rev()
13309        .map(|previous| {
13310            (
13311                previous.fingerprint.as_str(),
13312                previous.public_key_spki.as_str(),
13313            )
13314        })
13315        .collect();
13316    chain.push((&identity.fingerprint, &identity.public_key_spki));
13317    let signer_index = chain.iter().position(|(known_fingerprint, spki)| {
13318        *known_fingerprint == fingerprint && *spki == entry.public_key
13319    });
13320    let Some(signer_index) = signer_index else {
13321        return Err(invalid_feed(
13322            "entry signer is not this brain's identity (current or rotated-from)",
13323        ));
13324    };
13325    let lower_boundary = if signer_index == 0 {
13326        None
13327    } else {
13328        let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
13329            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13330        Some(prior.prior_head_seq)
13331    };
13332    let upper_boundary = if signer_index == identity.rotations.len() {
13333        None
13334    } else {
13335        let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
13336            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13337        Some(next.prior_head_seq)
13338    };
13339    if lower_boundary.is_some_and(|boundary| entry.seq <= boundary)
13340        || upper_boundary.is_some_and(|boundary| entry.seq > boundary)
13341    {
13342        return Err(invalid_feed(
13343            "entry signer is outside its authenticated rotation epoch",
13344        ));
13345    }
13346    let unsigned = UnsignedFeedEntry {
13347        v: entry.v,
13348        seq: entry.seq,
13349        ts: &entry.ts,
13350        brain: &entry.brain,
13351        public_key: &entry.public_key,
13352        kind: &entry.kind,
13353        op: &entry.op,
13354        pack_sha256: &entry.pack_sha256,
13355        files: &entry.files,
13356        removed: &entry.removed,
13357        prev_entry_hash: &entry.prev_entry_hash,
13358    };
13359    let message =
13360        serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
13361    let signature = URL_SAFE_NO_PAD
13362        .decode(&entry.sig)
13363        .map_err(|_| invalid_feed("signature is not base64url"))?;
13364    UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
13365        .verify(&message, &signature)
13366        .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
13367
13368    let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
13369    exact.push(b'\n');
13370    let actual_hash = format!("{:x}", Sha256::digest(&exact));
13371    if actual_hash != item.hash {
13372        return Err(invalid_feed("entry SHA-256 does not match"));
13373    }
13374    Ok(())
13375}
13376
13377// ─────────────────────────────────────────────────────────────────────────────
13378// key rotation — link.md §9.1: the new key, signed by the old one
13379// ─────────────────────────────────────────────────────────────────────────────
13380
13381/// The unsigned rotation statement in its normative field order.
13382#[derive(Serialize)]
13383struct UnsignedRotation<'a> {
13384    v: u8,
13385    op: &'a str,
13386    brain: &'a str,
13387    public_key: &'a str,
13388    new_brain: &'a str,
13389    new_public_key: &'a str,
13390    prior_head_seq: u64,
13391    prior_feed_hash: Option<&'a str>,
13392    ts: String,
13393}
13394
13395/// Durable intent for an in-flight key rotation. The hub's recovery contract
13396/// identifies an ambiguous retry by the exact statement bytes, so the
13397/// statement cannot be reconstructed from the key and feed boundary later:
13398/// its timestamp and signature would differ.
13399#[derive(Debug, Deserialize, Serialize)]
13400#[serde(deny_unknown_fields)]
13401struct RotationJournal {
13402    v: u8,
13403    origin: String,
13404    brain: String,
13405    old_brain: String,
13406    new_brain: String,
13407    prior_head_seq: u64,
13408    prior_feed_hash: Option<String>,
13409    statement: String,
13410}
13411
13412fn rotation_journal_path(key_path: &Path) -> PathBuf {
13413    let mut path = key_path.as_os_str().to_os_string();
13414    path.push(".rotation.json");
13415    PathBuf::from(path)
13416}
13417
13418fn read_rotation_journal(path: &Path) -> LinkResult<RotationJournal> {
13419    #[cfg(unix)]
13420    let file = {
13421        use std::os::fd::{AsRawFd as _, FromRawFd as _};
13422        use std::os::unix::ffi::OsStrExt as _;
13423        let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
13424            .map_err(|error| {
13425                bad_agent_key(&format!("cannot open the rotation journal parent: {error}"))
13426            })?;
13427        let leaf_name = path
13428            .file_name()
13429            .ok_or_else(|| bad_agent_key("the rotation journal path has no file name"))?;
13430        let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
13431        let fd = unsafe {
13432            libc::openat(
13433                parent.as_raw_fd(),
13434                leaf.as_ptr(),
13435                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13436            )
13437        };
13438        if fd < 0 {
13439            return Err(bad_agent_key(
13440                "the rotation journal must be an existing regular file without symlink ancestors",
13441            ));
13442        }
13443        unsafe { std::fs::File::from_raw_fd(fd) }
13444    };
13445    #[cfg(not(unix))]
13446    let file = std::fs::File::open(path)
13447        .map_err(|error| bad_agent_key(&format!("cannot read the rotation journal: {error}")))?;
13448    let metadata = file
13449        .metadata()
13450        .map_err(|error| bad_agent_key(&format!("cannot inspect the rotation journal: {error}")))?;
13451    if !metadata.is_file() || metadata.len() > MAX_REGISTRY_CARD_BYTES {
13452        return Err(bad_agent_key(
13453            "the rotation journal must be a bounded regular file",
13454        ));
13455    }
13456    #[cfg(unix)]
13457    {
13458        use std::os::unix::fs::PermissionsExt as _;
13459        if metadata.permissions().mode() & 0o077 != 0 {
13460            return Err(bad_agent_key(
13461                "the rotation journal is accessible to group/other; set mode 0600",
13462            ));
13463        }
13464    }
13465    serde_json::from_reader(file)
13466        .map_err(|_| bad_agent_key("the rotation journal is not valid exact JSON"))
13467}
13468
13469fn remove_rotation_journal(path: &Path) {
13470    #[cfg(unix)]
13471    {
13472        use std::os::fd::AsRawFd as _;
13473        use std::os::unix::ffi::OsStrExt as _;
13474        let Ok(parent) =
13475            open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
13476        else {
13477            return;
13478        };
13479        let Some(leaf_name) = path.file_name() else {
13480            return;
13481        };
13482        let Ok(leaf) = c_name(leaf_name.as_bytes(), &path.display().to_string()) else {
13483            return;
13484        };
13485        if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } == 0 {
13486            let _ = parent.sync_all();
13487        }
13488    }
13489    #[cfg(not(unix))]
13490    {
13491        let _ = std::fs::remove_file(path);
13492    }
13493}
13494
13495fn validate_rotation_journal(
13496    journal: &RotationJournal,
13497    cfg: &HubConfig,
13498    canonical_brain: &str,
13499    old_key: &AgentSigningKey,
13500    new_key: &AgentSigningKey,
13501    head: &Head,
13502) -> LinkResult<()> {
13503    if journal.v != 1
13504        || journal.origin != normalized_origin(&cfg.hub)?
13505        || journal.brain != canonical_brain
13506        || journal.old_brain != old_key.multikey
13507        || journal.new_brain != new_key.multikey
13508        || journal.prior_head_seq != head.seq
13509        || journal.prior_feed_hash != head.feed_hash
13510    {
13511        return Err(invalid_feed(
13512            "rotation journal does not match the verified key and feed boundary",
13513        ));
13514    }
13515    let statement: RotationStatement = serde_json::from_str(&journal.statement)
13516        .map_err(|_| invalid_feed("rotation journal statement did not parse exactly"))?;
13517    if statement.prior_head_seq != journal.prior_head_seq
13518        || statement.prior_feed_hash != journal.prior_feed_hash
13519        || statement.brain != old_key.multikey
13520        || statement.public_key != old_key.public_key_spki
13521        || statement.new_brain != new_key.multikey
13522        || statement.new_public_key != new_key.public_key_spki
13523    {
13524        return Err(invalid_feed(
13525            "rotation journal statement does not match its durable intent",
13526        ));
13527    }
13528    let identity = FeedIdentity {
13529        fingerprint: new_key.multikey.trim_start_matches("ed25519:").to_string(),
13530        public_key_spki: new_key.public_key_spki.clone(),
13531        previous: vec![PreviousIdentity {
13532            fingerprint: old_key.multikey.trim_start_matches("ed25519:").to_string(),
13533            public_key_spki: old_key.public_key_spki.clone(),
13534        }],
13535        rotations: vec![journal.statement.clone()],
13536    };
13537    verify_identity_chain(&identity, None)?;
13538    Ok(())
13539}
13540
13541/// What `dbmd key rotate` returns.
13542#[derive(Debug, Serialize)]
13543pub struct RotationReport {
13544    /// The brain id rotated.
13545    pub brain: String,
13546    /// The NEW identity the hub now serves.
13547    pub multikey: String,
13548    /// Where the new PKCS#8 secret landed (0600).
13549    #[serde(rename = "keyFile")]
13550    pub key_file: String,
13551    /// Prior identities (newest first) the feed still verifies against.
13552    pub previous: Vec<String>,
13553}
13554
13555/// Rotate a self-custodied brain's key: mint a fresh keypair, build the
13556/// §9.1 statement — the new key plus exact prior feed boundary, signed by the
13557/// OLD key in normative serialization — and send it to the hub. The new secret
13558/// is durably created at 0600 before the POST; an existing output is reused for
13559/// idempotent retry/reconciliation. The old key is left untouched.
13560pub fn rotate_brain_key(
13561    cfg: &HubConfig,
13562    brain: &str,
13563    old_key: &AgentSigningKey,
13564    out: &Path,
13565) -> LinkResult<RotationReport> {
13566    require_hardened_filesystem("key rotation")?;
13567    require_safe_ref(brain)?;
13568    // The new private key must be durable *before* the hub can accept its
13569    // public half. An existing file is the retry/reconciliation path after an
13570    // ambiguous network failure: reuse it, never generate another identity.
13571    let new_key = if out.exists() {
13572        load_signing_key(out)?
13573    } else {
13574        let rng = ring::rand::SystemRandom::new();
13575        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
13576            .map_err(|_| bad_agent_key("key generation failed"))?;
13577        let pair = agent_keypair(pkcs8.as_ref())?;
13578        let (public_key_spki, multikey) = public_identity_for(&pair);
13579        write_secret_new(
13580            out,
13581            format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
13582        )?;
13583        AgentSigningKey {
13584            pkcs8: pkcs8.as_ref().to_vec(),
13585            multikey,
13586            public_key_spki,
13587        }
13588    };
13589    let new_spki = new_key.public_key_spki.clone();
13590    let new_multikey = new_key.multikey.clone();
13591    let journal_path = rotation_journal_path(out);
13592    let before_v2 = v2_verified_head(cfg, brain)?;
13593    let (canonical_brain, served_identity, observed_head, v2_profile) =
13594        if let Some(head) = before_v2 {
13595            let observed = Head {
13596                brain: head.brain_id.clone(),
13597                seq: head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
13598                updated_at: head
13599                    .pointer
13600                    .as_ref()
13601                    .map(|pointer| pointer.signed_at.clone()),
13602                feed_hash: head
13603                    .pointer
13604                    .as_ref()
13605                    .map(|pointer| pointer.feed_hash.clone()),
13606                verified: true,
13607            };
13608            let identity = v2_identity(&head.identity);
13609            let canonical = head.brain_id.clone();
13610            accept_v2_head(cfg, &head)?;
13611            (canonical, identity, observed, true)
13612        } else {
13613            let remote = verified_remote_head(cfg, brain, false)?;
13614            let identity = remote
13615                .identity
13616                .clone()
13617                .ok_or_else(|| invalid_feed("cannot rotate a brain with no signed identity"))?;
13618            (remote.head.brain.clone(), identity, remote.head, false)
13619        };
13620    let served_multikey = format!("ed25519:{}", served_identity.fingerprint);
13621    let already_rotated = served_multikey == new_multikey;
13622    // A completed invocation removes the journal only after the server has
13623    // acknowledged its recovery boundary. If the verified identity advanced
13624    // but the journal remains, the prior response was ambiguous: replay the
13625    // exact signed statement so a v2 hub can repair/confirm its checkpoint.
13626    if already_rotated && !journal_path.exists() {
13627        remove_rotation_journal(&journal_path);
13628        return Ok(RotationReport {
13629            brain: brain.to_string(),
13630            multikey: new_multikey,
13631            key_file: out.display().to_string(),
13632            previous: served_identity
13633                .previous
13634                .iter()
13635                .map(|identity| format!("ed25519:{}", identity.fingerprint))
13636                .collect(),
13637        });
13638    }
13639    if !already_rotated && served_multikey != old_key.multikey {
13640        return Err(invalid_feed(
13641            "the supplied old key is not the brain's verified current identity",
13642        ));
13643    }
13644
13645    let journal = if journal_path.exists() {
13646        read_rotation_journal(&journal_path)?
13647    } else {
13648        let ts = crate::now()
13649            .with_timezone(&chrono::Utc)
13650            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
13651            .to_string();
13652        let unsigned = serde_json::to_string(&UnsignedRotation {
13653            v: 1,
13654            op: "rotate",
13655            brain: &old_key.multikey,
13656            public_key: &old_key.public_key_spki,
13657            new_brain: &new_multikey,
13658            new_public_key: &new_spki,
13659            prior_head_seq: observed_head.seq,
13660            prior_feed_hash: observed_head.feed_hash.as_deref(),
13661            ts,
13662        })
13663        .expect("serialize rotation");
13664        let old_pair = agent_keypair(&old_key.pkcs8)?;
13665        let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
13666        let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
13667        let journal = RotationJournal {
13668            v: 1,
13669            origin: normalized_origin(&cfg.hub)?,
13670            brain: canonical_brain.clone(),
13671            old_brain: old_key.multikey.clone(),
13672            new_brain: new_multikey.clone(),
13673            prior_head_seq: observed_head.seq,
13674            prior_feed_hash: observed_head.feed_hash.clone(),
13675            statement,
13676        };
13677        let mut exact = serde_json::to_vec(&journal)
13678            .map_err(|_| invalid_feed("could not serialize rotation journal"))?;
13679        exact.push(b'\n');
13680        if write_secret_new(&journal_path, &exact).is_err() {
13681            // A concurrent retry may have won the O_EXCL race. Only an exact,
13682            // fully validated journal is allowed to recover that race.
13683            read_rotation_journal(&journal_path)?
13684        } else {
13685            journal
13686        }
13687    };
13688    validate_rotation_journal(
13689        &journal,
13690        cfg,
13691        &canonical_brain,
13692        old_key,
13693        &new_key,
13694        &observed_head,
13695    )?;
13696
13697    let body = json!({ "statement": journal.statement });
13698    let path = format!("/api/hub/brains/{brain}/rotate");
13699    let attempted = request(cfg, "POST", &path, Some(&body), Auth::Required);
13700    let attempted_failure = match attempted {
13701        Ok(response) if (200..300).contains(&response.status) => None,
13702        Ok(response) => Some(ensure_ok(response, "key rotate").unwrap_err()),
13703        Err(error) => Some(error),
13704    };
13705
13706    // A 2xx body is not authority, and a failed response may have followed a
13707    // committed mutation. In both cases success means the normal verifier sees
13708    // an append-only rotation chain ending at the durable new key.
13709    let identity = if v2_profile {
13710        match v2_verified_head(cfg, brain) {
13711            Ok(Some(after)) => {
13712                let identity = v2_identity(&after.identity);
13713                accept_v2_head(cfg, &after)?;
13714                identity
13715            }
13716            Ok(None) => {
13717                return Err(attempted_failure.unwrap_or_else(|| {
13718                    invalid_feed("rotated v2 brain no longer serves a v2 head")
13719                }));
13720            }
13721            Err(error) => return Err(attempted_failure.unwrap_or(error)),
13722        }
13723    } else {
13724        match verified_remote_head(cfg, brain, false) {
13725            Ok(after) => after
13726                .identity
13727                .ok_or_else(|| invalid_feed("rotated brain has no verified identity"))?,
13728            Err(error) => return Err(attempted_failure.unwrap_or(error)),
13729        }
13730    };
13731    if format!("ed25519:{}", identity.fingerprint) != new_multikey
13732        || identity.public_key_spki != new_spki
13733    {
13734        return Err(attempted_failure.unwrap_or_else(|| {
13735            invalid_feed("hub acknowledged rotation without committing the verified new identity")
13736        }));
13737    }
13738    if v2_profile {
13739        if let Some(error) = attempted_failure {
13740            // Seeing the new public identity is insufficient: v2 rotation is
13741            // successful only after the endpoint has forced and acknowledged
13742            // the recovery checkpoint that contains it. Keep the journal for
13743            // an exact retry after any non-2xx or transport ambiguity.
13744            return Err(error);
13745        }
13746    }
13747    let previous = identity
13748        .previous
13749        .iter()
13750        .map(|prior| format!("ed25519:{}", prior.fingerprint))
13751        .collect();
13752    remove_rotation_journal(&journal_path);
13753
13754    Ok(RotationReport {
13755        brain: brain.to_string(),
13756        multikey: new_multikey,
13757        key_file: out.display().to_string(),
13758        previous,
13759    })
13760}
13761
13762// ─────────────────────────────────────────────────────────────────────────────
13763// mirror — verified replication: the whole feed + files, re-servable
13764// ─────────────────────────────────────────────────────────────────────────────
13765
13766/// What `dbmd mirror` materialized.
13767#[derive(Debug, Serialize)]
13768pub struct MirrorReport {
13769    /// The brain id.
13770    pub brain: String,
13771    /// The mirrored feed head.
13772    #[serde(rename = "headSeq")]
13773    pub head_seq: u64,
13774    /// The head entry hash (the feed's advertised converged state).
13775    #[serde(rename = "feedHash")]
13776    pub feed_hash: Option<String>,
13777    /// Signed feed entries verified and stored.
13778    pub entries: u64,
13779    /// The brain's multikey, pinned in `.dbmd/config` (TOFU).
13780    pub pinned: String,
13781    /// Store files materialized by the pull.
13782    pub files: usize,
13783}
13784
13785/// The mirror state directory, relative to the mirror root.
13786pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
13787
13788/// Fully re-verified mirror material suitable for a read-only re-server.
13789#[derive(Debug)]
13790pub struct VerifiedMirrorMaterial {
13791    pub brain: String,
13792    pub head_seq: u64,
13793    pub feed_hash: Option<String>,
13794    pub identity: serde_json::Value,
13795    /// Sequence, exact normative entry JSON without newline, and entry hash.
13796    pub entries: Vec<(u64, String, String)>,
13797    pub pack_sha256: Option<String>,
13798}
13799
13800#[derive(Deserialize)]
13801#[serde(deny_unknown_fields)]
13802struct StoredMirrorHead {
13803    brain: String,
13804    #[serde(rename = "headSeq")]
13805    head_seq: u64,
13806    #[serde(rename = "feedHash")]
13807    feed_hash: Option<String>,
13808}
13809
13810/// Re-verify mirror metadata, every signature/hash/rotation boundary, and the
13811/// exact snapshot pack before `dbmd serve` exposes any bytes.
13812pub fn verify_mirror_material(
13813    head_bytes: &[u8],
13814    identity_bytes: &[u8],
13815    feed_bytes: &[Vec<u8>],
13816    snapshot_pack: Option<&[u8]>,
13817    expected_anchor: &str,
13818) -> LinkResult<VerifiedMirrorMaterial> {
13819    let snapshot_hash = snapshot_pack
13820        .filter(|pack| !pack.is_empty())
13821        .map(content_sha256);
13822    verify_mirror_material_with_pack_hash(
13823        head_bytes,
13824        identity_bytes,
13825        feed_bytes,
13826        snapshot_hash.as_deref(),
13827        expected_anchor,
13828    )
13829}
13830
13831/// Re-verify mirror metadata against a snapshot digest computed from a held
13832/// no-follow file capability. This lets `dbmd serve` authenticate and retain a
13833/// large pack without ever buffering the pack in process memory.
13834pub fn verify_mirror_material_with_pack_hash(
13835    head_bytes: &[u8],
13836    identity_bytes: &[u8],
13837    feed_bytes: &[Vec<u8>],
13838    snapshot_pack_sha256: Option<&str>,
13839    expected_anchor: &str,
13840) -> LinkResult<VerifiedMirrorMaterial> {
13841    let head: StoredMirrorHead = serde_json::from_slice(head_bytes)
13842        .map_err(|_| invalid_feed("stored mirror head did not parse exactly"))?;
13843    require_safe_ref(&head.brain)?;
13844    if head.head_seq > MAX_FEED_REPLAY_ENTRIES || feed_bytes.len() as u64 != head.head_seq {
13845        return Err(invalid_feed(
13846            "stored mirror feed count does not match its bounded head sequence",
13847        ));
13848    }
13849    let aggregate = feed_bytes
13850        .iter()
13851        .try_fold(0u64, |total, bytes| total.checked_add(bytes.len() as u64))
13852        .ok_or_else(|| invalid_feed("stored mirror feed size overflow"))?;
13853    if aggregate > MAX_FEED_REPLAY_BYTES {
13854        return Err(invalid_feed(
13855            "stored mirror feed metadata exceeds the aggregate limit",
13856        ));
13857    }
13858    let identity: FeedIdentity = serde_json::from_slice(identity_bytes)
13859        .map_err(|_| invalid_feed("stored mirror identity did not parse exactly"))?;
13860    let anchor = verify_identity_chain(&identity, None)?;
13861    if anchor != expected_anchor {
13862        return Err(invalid_feed(
13863            "stored mirror identity does not descend from the explicitly trusted anchor",
13864        ));
13865    }
13866
13867    let mut entries = Vec::with_capacity(feed_bytes.len());
13868    let mut items = Vec::with_capacity(feed_bytes.len());
13869    let mut previous_hash = None;
13870    let mut pack_sha256 = None;
13871    for (index, bytes) in feed_bytes.iter().enumerate() {
13872        let exact = bytes
13873            .strip_suffix(b"\n")
13874            .ok_or_else(|| invalid_feed("stored feed entry lacks its exact trailing newline"))?;
13875        if exact.ends_with(b"\n") {
13876            return Err(invalid_feed("stored feed entry has extra trailing bytes"));
13877        }
13878        let entry: FeedEntry = serde_json::from_slice(exact)
13879            .map_err(|_| invalid_feed("stored feed entry did not parse exactly"))?;
13880        let expected_seq = index as u64 + 1;
13881        if entry.seq != expected_seq || entry.prev_entry_hash != previous_hash {
13882            return Err(invalid_feed(
13883                "stored mirror feed is not contiguous and hash-chained",
13884            ));
13885        }
13886        let canonical = serde_json::to_vec(&entry)
13887            .map_err(|_| invalid_feed("could not canonicalize stored feed entry"))?;
13888        if canonical != exact {
13889            return Err(invalid_feed(
13890                "stored feed entry is not in normative serialization",
13891            ));
13892        }
13893        let hash = content_sha256(bytes);
13894        let item = FeedItem {
13895            hash: hash.clone(),
13896            entry,
13897        };
13898        verify_feed_item(&item, &identity)?;
13899        previous_hash = Some(hash.clone());
13900        if expected_seq == head.head_seq {
13901            pack_sha256 = Some(item.entry.pack_sha256.clone());
13902        }
13903        entries.push((
13904            expected_seq,
13905            std::str::from_utf8(exact)
13906                .map_err(|_| invalid_feed("stored feed entry is not UTF-8"))?
13907                .to_string(),
13908            hash,
13909        ));
13910        items.push(item);
13911    }
13912    if previous_hash != head.feed_hash {
13913        return Err(invalid_feed(
13914            "stored mirror feed does not converge on its advertised head",
13915        ));
13916    }
13917    verify_rotation_feed_boundaries(&identity, None, &items, head.head_seq)?;
13918    match (head.head_seq, snapshot_pack_sha256, pack_sha256.as_deref()) {
13919        (0, None, None) => {}
13920        (_, Some(actual), Some(expected)) if actual == expected => {}
13921        _ => {
13922            return Err(LinkError::InvalidPack {
13923                message: "stored snapshot pack does not match the signed head digest".to_string(),
13924            });
13925        }
13926    }
13927    let identity_value = serde_json::to_value(&identity)
13928        .map_err(|_| invalid_feed("could not serialize verified mirror identity"))?;
13929    Ok(VerifiedMirrorMaterial {
13930        brain: head.brain,
13931        head_seq: head.head_seq,
13932        feed_hash: head.feed_hash,
13933        identity: identity_value,
13934        entries,
13935        pack_sha256,
13936    })
13937}
13938
13939/// SHA-256 hex of one feed entry's stored bytes (`exact JSON + "\n"`) — the
13940/// entry hash every consumer recomputes (SPEC §5.3).
13941pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
13942    format!(
13943        "{:x}",
13944        Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
13945    )
13946}
13947
13948/// SHA-256 hex for content a signed manifest names. Exposed for the thin
13949/// `dbmd serve` adapter; cryptographic verification remains centralized here.
13950pub fn content_sha256(bytes: &[u8]) -> String {
13951    format!("{:x}", Sha256::digest(bytes))
13952}
13953
13954/// SHA-256 a stream with a fixed-size working buffer.
13955pub fn content_sha256_reader(mut reader: impl Read) -> std::io::Result<String> {
13956    let mut digest = Sha256::new();
13957    let mut buffer = [0u8; 64 * 1024];
13958    loop {
13959        let read = reader.read(&mut buffer)?;
13960        if read == 0 {
13961            break;
13962        }
13963        digest.update(&buffer[..read]);
13964    }
13965    Ok(format!("{:x}", digest.finalize()))
13966}
13967
13968/// Replicate a brain with full verification (link.md §5.4 over the WHOLE
13969/// chain, not just the head): every entry's signature, hash, sequence
13970/// contiguity, prev-hash linkage, rotation chain, and exact signed pack are
13971/// checked in a sibling staging directory. Only then is the old mirror swapped
13972/// out through an atomic directory exchange. Every stage, install, and cleanup
13973/// operation is relative to one held no-follow parent-directory capability, so
13974/// renaming an ancestor cannot redirect any write or deletion.
13975#[cfg_attr(windows, allow(unreachable_code, unused_variables))]
13976pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
13977    require_hardened_filesystem("mirror")?;
13978    require_safe_ref(brain)?;
13979    #[cfg(windows)]
13980    {
13981        let _ = (cfg, dest);
13982        return Err(LinkError::UnsupportedPlatform {
13983            operation: "atomic whole-mirror replacement on Windows",
13984        });
13985    }
13986    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
13987    let name = dest
13988        .file_name()
13989        .and_then(|name| name.to_str())
13990        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
13991        .ok_or_else(|| LinkError::UnsafePath {
13992            path: dest.display().to_string(),
13993        })?;
13994    #[cfg(unix)]
13995    let parent_dir = open_or_create_dir_nofollow(parent)?;
13996    #[cfg(unix)]
13997    use std::os::fd::AsRawFd as _;
13998    #[cfg(unix)]
13999    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
14000    #[cfg(unix)]
14001    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
14002        None => false,
14003        Some(true) => true,
14004        Some(false) => {
14005            return Err(LinkError::UnsafePath {
14006                path: dest.display().to_string(),
14007            });
14008        }
14009    };
14010
14011    // A fixed backup was used by pre-hardening builds. Never interpret or
14012    // delete an attacker-planted entry at that name; require manual recovery.
14013    #[cfg(unix)]
14014    let legacy_backup_name = c_name(
14015        format!(".{name}.dbmd-backup").as_bytes(),
14016        &dest.display().to_string(),
14017    )?;
14018    #[cfg(unix)]
14019    if entry_is_dir_at(parent_dir.as_raw_fd(), &legacy_backup_name)?.is_some() {
14020        return Err(LinkError::UnsafePath {
14021            path: parent
14022                .join(format!(".{name}.dbmd-backup"))
14023                .display()
14024                .to_string(),
14025        });
14026    }
14027
14028    let nonce = std::time::SystemTime::now()
14029        .duration_since(std::time::UNIX_EPOCH)
14030        .unwrap_or_default()
14031        .as_nanos();
14032    let stage_label = format!(".{name}.dbmd-stage-{}-{nonce}", std::process::id());
14033    #[cfg(unix)]
14034    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
14035    #[cfg(unix)]
14036    let stage_dir = create_dir_exclusive_at(
14037        parent_dir.as_raw_fd(),
14038        &stage_name,
14039        &dest.display().to_string(),
14040    )?;
14041
14042    let assembled = (|| -> LinkResult<MirrorReport> {
14043        let remote = verified_remote_head(cfg, brain, true)?;
14044        let brain_id = remote.head.brain.clone();
14045        let identity = remote
14046            .identity
14047            .as_ref()
14048            .ok_or_else(|| invalid_feed("brain has no signed identity"))?;
14049        let anchor = remote
14050            .anchor
14051            .clone()
14052            .ok_or_else(|| invalid_feed("brain has no verified identity anchor"))?;
14053        let pack = download_verified_snapshot_pack(cfg, &brain_id, &remote)?;
14054        let snapshot_entries = parse_store_pack(pack.clone())?;
14055        let snapshot_count = snapshot_entries.len();
14056        let mut staged_entries = snapshot_entries;
14057        staged_entries.push((format!("{MIRROR_REL_DIR}/snapshot.pack"), pack));
14058        for item in &remote.entries {
14059            let mut exact = serde_json::to_vec(&item.entry)
14060                .map_err(|_| invalid_feed("could not serialize signed feed entry"))?;
14061            exact.push(b'\n');
14062            if format!("{:x}", Sha256::digest(&exact)) != item.hash {
14063                return Err(invalid_feed(
14064                    "serialized mirror entry differs from its verified hash",
14065                ));
14066            }
14067            staged_entries.push((
14068                format!("{MIRROR_REL_DIR}/feed/{}.json", item.entry.seq),
14069                exact,
14070            ));
14071        }
14072        let mut identity_bytes = serde_json::to_vec(identity)
14073            .map_err(|_| invalid_feed("could not serialize verified identity chain"))?;
14074        identity_bytes.push(b'\n');
14075        staged_entries.push((format!("{MIRROR_REL_DIR}/identity.json"), identity_bytes));
14076        let mut head_bytes = serde_json::to_vec(&json!({
14077            "brain": brain_id,
14078            "headSeq": remote.head.seq,
14079            "feedHash": remote.head.feed_hash,
14080        }))
14081        .map_err(|_| invalid_feed("could not serialize verified mirror head"))?;
14082        head_bytes.push(b'\n');
14083        staged_entries.push((format!("{MIRROR_REL_DIR}/head.json"), head_bytes));
14084        staged_entries.push((
14085            CONFIG_REL_PATH.to_string(),
14086            format!("hub = {}\npin = {anchor}\n", cfg.hub).into_bytes(),
14087        ));
14088        #[cfg(unix)]
14089        write_pull_entries_beneath_dir(&stage_dir, &staged_entries)?;
14090
14091        Ok(MirrorReport {
14092            brain: brain_id,
14093            head_seq: remote.head.seq,
14094            feed_hash: remote.head.feed_hash,
14095            entries: remote.entries.len() as u64,
14096            pinned: anchor,
14097            files: snapshot_count,
14098        })
14099    })();
14100
14101    let report = match assembled {
14102        Ok(report) => report,
14103        Err(error) => {
14104            #[cfg(unix)]
14105            let _ = remove_tree_at(
14106                parent_dir.as_raw_fd(),
14107                &stage_name,
14108                &dest.display().to_string(),
14109            );
14110            return Err(error);
14111        }
14112    };
14113
14114    #[cfg(unix)]
14115    if let Err(error) =
14116        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
14117    {
14118        let _ = remove_tree_at(
14119            parent_dir.as_raw_fd(),
14120            &stage_name,
14121            &dest.display().to_string(),
14122        );
14123        return Err(error);
14124    }
14125    // An exchange leaves the old mirror at the unique stage name. Cleanup is
14126    // capability-relative and never follows symlinks in hostile old content.
14127    #[cfg(unix)]
14128    if dest_exists {
14129        remove_tree_at(
14130            parent_dir.as_raw_fd(),
14131            &stage_name,
14132            &dest.display().to_string(),
14133        )?;
14134    }
14135    #[cfg(unix)]
14136    parent_dir.sync_all()?;
14137    Ok(report)
14138}
14139
14140fn verified_remote_head(
14141    cfg: &HubConfig,
14142    brain: &str,
14143    require_full_chain: bool,
14144) -> LinkResult<VerifiedRemote> {
14145    require_hardened_filesystem("verified link.md state")?;
14146    require_safe_ref(brain)?;
14147    // Refuse an unsafe state root before sending credentials or consulting an
14148    // untrusted card, and retain this exact directory inode for the complete
14149    // checkpoint transaction.
14150    let trust_directory = open_trust_dir(cfg)?;
14151    let path = format!("/api/hub/brains/{brain}");
14152    let body = ensure_ok(
14153        request(cfg, "GET", &path, None, Auth::Required)?,
14154        "subscribe",
14155    )?;
14156    let resolved_brain = body
14157        .get("id")
14158        .and_then(Value::as_str)
14159        .filter(|id| crate::ulid::is_ulid(id))
14160        .ok_or_else(|| invalid_feed("brain card has no canonical ULID id"))?
14161        .to_string();
14162    if crate::ulid::is_ulid(brain) && resolved_brain != brain {
14163        return Err(invalid_feed(
14164            "brain card id differs from the explicitly requested brain id",
14165        ));
14166    }
14167    // The card supplies the canonical ULID. Lock alias + canonical keys in
14168    // deterministic filename order, then hold both through verify + save.
14169    // Concurrent aliases for the same brain therefore converge on one
14170    // checkpoint instead of establishing independent TOFU universes.
14171    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &resolved_brain])?;
14172    let (pinned, alias_binding) =
14173        load_canonical_pin(cfg, &trust_directory, brain, &resolved_brain)?;
14174    let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
14175    let advertised_hash = body
14176        .get("feedHash")
14177        .and_then(Value::as_str)
14178        .map(str::to_string);
14179    let updated_at = body
14180        .get("updatedAt")
14181        .and_then(Value::as_str)
14182        .map(str::to_string);
14183    if let Some(pin) = &pinned {
14184        if seq < pin.head_seq {
14185            return Err(invalid_feed(format!(
14186                "feed rollback: hub advertised sequence {seq}, local checkpoint is {}",
14187                pin.head_seq
14188            )));
14189        }
14190        if seq == pin.head_seq && advertised_hash != pin.feed_hash {
14191            return Err(invalid_feed(
14192                "feed equivocation: the checkpoint sequence now has a different hash",
14193            ));
14194        }
14195    }
14196    if seq == 0 {
14197        if advertised_hash.is_some() {
14198            return Err(invalid_feed("an empty feed advertised a head hash"));
14199        }
14200        let identity: FeedIdentity = serde_json::from_value(
14201            body.get("identity")
14202                .cloned()
14203                .ok_or_else(|| invalid_feed("empty brain card has no signed identity"))?,
14204        )
14205        .map_err(|_| invalid_feed("empty brain card has an invalid identity"))?;
14206        let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
14207        // A valid old-key-signed rotation is still inconsistent if it commits
14208        // to history that the same card now claims never existed. Run the
14209        // identical feed-boundary proof used by non-empty heads before this
14210        // identity can become a durable TOFU checkpoint.
14211        verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], seq)?;
14212        save_canonical_pin_and_alias(
14213            cfg,
14214            &trust_directory,
14215            brain,
14216            &resolved_brain,
14217            TrustState {
14218                v: 2,
14219                origin: normalized_origin(&cfg.hub)?,
14220                requested: resolved_brain.clone(),
14221                brain: resolved_brain.clone(),
14222                home: None,
14223                anchor: anchor.clone(),
14224                current: format!("ed25519:{}", identity.fingerprint),
14225                head_seq: 0,
14226                feed_hash: None,
14227                rotations: identity.rotations.clone(),
14228                hub_signer: None,
14229                protocol_profile: None,
14230            },
14231            alias_binding.as_ref(),
14232        )?;
14233        return Ok(VerifiedRemote {
14234            head: Head {
14235                brain: resolved_brain,
14236                seq,
14237                updated_at,
14238                feed_hash: None,
14239                verified: true,
14240            },
14241            identity: Some(identity),
14242            head_entry: None,
14243            entries: Vec::new(),
14244            anchor: Some(anchor),
14245        });
14246    }
14247    if advertised_hash.as_ref().is_none_or(|hash| !is_sha256(hash)) {
14248        return Err(invalid_feed(
14249            "non-empty feed did not advertise a valid SHA-256 head",
14250        ));
14251    }
14252
14253    // On first contact the signed head itself is the TOFU checkpoint. A full
14254    // history replay cannot add authority before an anchor exists; mirrors
14255    // still request the complete chain because they promise an archival copy.
14256    let replay_head_only = !require_full_chain
14257        && pinned
14258            .as_ref()
14259            .is_none_or(|checkpoint| checkpoint.head_seq == seq);
14260    let mut after = if replay_head_only {
14261        seq - 1
14262    } else if require_full_chain || pinned.is_none() {
14263        0
14264    } else {
14265        pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq)
14266    };
14267    let mut expected_seq = after + 1;
14268    let mut previous_hash = if require_full_chain || pinned.is_none() || replay_head_only {
14269        None
14270    } else {
14271        pinned
14272            .as_ref()
14273            .and_then(|checkpoint| checkpoint.feed_hash.clone())
14274    };
14275    let mut identity: Option<FeedIdentity> = None;
14276    let mut anchor: Option<String> = None;
14277    let mut head_entry: Option<FeedItem> = None;
14278    let mut all_entries = Vec::new();
14279    let mut observed_entries = Vec::new();
14280    let replay_count = seq
14281        .checked_sub(after)
14282        .ok_or_else(|| invalid_feed("feed replay range moved backward"))?;
14283    if replay_count > MAX_FEED_REPLAY_ENTRIES {
14284        return Err(invalid_feed(format!(
14285            "feed replay requires {replay_count} entries, over the client cap"
14286        )));
14287    }
14288    let mut replay_bytes = 0u64;
14289
14290    loop {
14291        let feed_bytes = ensure_raw_ok(
14292            request_raw(
14293                cfg,
14294                "GET",
14295                &format!("/api/hub/brains/{brain}/feed?after={after}&limit=100"),
14296                None,
14297                Auth::Required,
14298                MAX_FEED_RESPONSE_BYTES,
14299            )?,
14300            "subscribe feed",
14301        )?;
14302        let feed: FeedResponse = serde_json::from_slice(&feed_bytes)
14303            .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
14304        if feed.head_seq != seq || feed.feed_hash != advertised_hash {
14305            return Err(invalid_feed("brain card and feed head disagree"));
14306        }
14307        if feed.entries.len() > FEED_PAGE_LIMIT {
14308            return Err(invalid_feed("feed page exceeds the requested entry limit"));
14309        }
14310        if feed.scope_limited {
14311            if require_full_chain {
14312                return Err(invalid_feed(
14313                    "path-scoped grants cannot verify a full snapshot chain",
14314                ));
14315            }
14316            return Ok(VerifiedRemote {
14317                head: Head {
14318                    brain: resolved_brain,
14319                    seq,
14320                    updated_at,
14321                    feed_hash: advertised_hash,
14322                    verified: false,
14323                },
14324                identity: None,
14325                head_entry: None,
14326                entries: Vec::new(),
14327                anchor: None,
14328            });
14329        }
14330        let page_identity = feed
14331            .identity
14332            .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
14333        let page_anchor = verify_identity_chain(&page_identity, pinned.as_ref())?;
14334        if identity
14335            .as_ref()
14336            .is_some_and(|existing| existing != &page_identity)
14337        {
14338            return Err(invalid_feed("identity changed while reading the feed"));
14339        }
14340        if anchor
14341            .as_ref()
14342            .is_some_and(|existing| existing != &page_anchor)
14343        {
14344            return Err(invalid_feed(
14345                "identity anchor changed while reading the feed",
14346            ));
14347        }
14348        identity = Some(page_identity.clone());
14349        if anchor.is_none() {
14350            anchor = Some(page_anchor);
14351        }
14352        if feed.entries.is_empty() {
14353            return Err(invalid_feed("feed page was empty before the signed head"));
14354        }
14355
14356        for item in feed.entries {
14357            if item.entry.seq != expected_seq {
14358                return Err(invalid_feed(format!(
14359                    "expected entry {expected_seq}, feed served {}",
14360                    item.entry.seq
14361                )));
14362            }
14363            if item.entry.seq > seq {
14364                return Err(invalid_feed("feed advanced past the card snapshot"));
14365            }
14366            if !replay_head_only && item.entry.prev_entry_hash != previous_hash {
14367                return Err(invalid_feed(format!(
14368                    "entry {} does not chain to the local checkpoint",
14369                    item.entry.seq
14370                )));
14371            }
14372            verify_feed_item(&item, &page_identity)?;
14373            reject_retired_signer_after_checkpoint(&page_identity, pinned.as_ref(), &item)?;
14374            replay_bytes = replay_bytes.saturating_add(
14375                serde_json::to_vec(&item)
14376                    .map_err(|_| invalid_feed("could not size feed entry"))?
14377                    .len() as u64,
14378            );
14379            if replay_bytes > MAX_FEED_REPLAY_BYTES {
14380                return Err(invalid_feed("feed replay metadata exceeds the client cap"));
14381            }
14382            previous_hash = Some(item.hash.clone());
14383            after = item.entry.seq;
14384            expected_seq = expected_seq
14385                .checked_add(1)
14386                .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
14387            if require_full_chain {
14388                all_entries.push(item.clone());
14389            }
14390            observed_entries.push(item.clone());
14391            head_entry = Some(item);
14392        }
14393        if after == seq {
14394            break;
14395        }
14396    }
14397
14398    if head_entry.as_ref().map(|item| &item.hash) != advertised_hash.as_ref() {
14399        return Err(invalid_feed(
14400            "verified chain does not converge on the advertised head",
14401        ));
14402    }
14403    let identity = identity.ok_or_else(|| invalid_feed("feed has no brain identity"))?;
14404    let anchor = anchor.ok_or_else(|| invalid_feed("feed has no identity anchor"))?;
14405    verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &observed_entries, seq)?;
14406    save_canonical_pin_and_alias(
14407        cfg,
14408        &trust_directory,
14409        brain,
14410        &resolved_brain,
14411        TrustState {
14412            v: 2,
14413            origin: normalized_origin(&cfg.hub)?,
14414            requested: resolved_brain.clone(),
14415            brain: resolved_brain.clone(),
14416            home: None,
14417            anchor: anchor.clone(),
14418            current: format!("ed25519:{}", identity.fingerprint),
14419            head_seq: seq,
14420            feed_hash: advertised_hash.clone(),
14421            rotations: identity.rotations.clone(),
14422            hub_signer: pinned.as_ref().and_then(|state| state.hub_signer.clone()),
14423            protocol_profile: pinned
14424                .as_ref()
14425                .and_then(|state| state.protocol_profile.clone()),
14426        },
14427        alias_binding.as_ref(),
14428    )?;
14429    Ok(VerifiedRemote {
14430        head: Head {
14431            brain: resolved_brain,
14432            seq,
14433            updated_at,
14434            feed_hash: advertised_hash,
14435            verified: true,
14436        },
14437        identity: Some(identity),
14438        head_entry,
14439        entries: all_entries,
14440        anchor: Some(anchor),
14441    })
14442}
14443
14444/// Read and locally verify the brain's current signed feed head. Identity
14445/// rotation is accepted only through an old-key-signed chain rooted at the
14446/// local TOFU anchor; sequence and hash checkpoints reject rollback and
14447/// equivocation across invocations.
14448pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
14449    if let Some(verified) = v2_verified_head(cfg, brain)? {
14450        let observation = Head {
14451            brain: verified.brain_id.clone(),
14452            seq: verified.pointer.as_ref().map_or(0, |pointer| pointer.seq),
14453            updated_at: verified
14454                .pointer
14455                .as_ref()
14456                .map(|pointer| pointer.signed_at.clone()),
14457            feed_hash: verified
14458                .pointer
14459                .as_ref()
14460                .map(|pointer| pointer.feed_hash.clone()),
14461            verified: true,
14462        };
14463        accept_v2_head(cfg, &verified)?;
14464        return Ok(observation);
14465    }
14466    Ok(verified_remote_head(cfg, brain, false)?.head)
14467}
14468
14469#[cfg(test)]
14470mod tests {
14471    use super::*;
14472
14473    const TEST_BRAIN_ID: &str = "01j5qc3v9k4ym8rwbn2tqe6f7d";
14474
14475    fn upload_declaration(index: usize, coordinate_len: usize) -> Value {
14476        json!({
14477            "sha256": "a".repeat(64),
14478            "bytes": 10,
14479            "coordinates": [format!("records/notes/{}-{}.md", "n".repeat(coordinate_len), index)],
14480        })
14481    }
14482
14483    #[test]
14484    fn upload_reservations_batch_by_count_and_by_size() {
14485        // A real store's first push declares one blob per changed file, which
14486        // does not fit in one request. Every batch has to stay inside both
14487        // bounds, and concatenating them has to reproduce the original order.
14488        let declarations: Vec<Value> = (0..5_000).map(|i| upload_declaration(i, 8)).collect();
14489        let batches = batch_upload_declarations(declarations.clone());
14490
14491        assert!(batches.len() > 1, "5,000 blobs must not ride one request");
14492        for batch in &batches {
14493            assert!(batch.len() <= MAX_UPLOAD_RESERVATION_BLOBS);
14494            let bytes = serde_json::to_string(&json!({ "blobs": batch }))
14495                .expect("batch serializes")
14496                .len();
14497            assert!(
14498                bytes <= MAX_UPLOAD_RESERVATION_BYTES + 1_024,
14499                "batch body {bytes} exceeds the reservation budget"
14500            );
14501        }
14502        let flattened: Vec<Value> = batches.into_iter().flatten().collect();
14503        assert_eq!(
14504            flattened, declarations,
14505            "batching must preserve the set and order"
14506        );
14507    }
14508
14509    #[test]
14510    fn only_load_shaped_hub_answers_are_worth_asking_again() {
14511        // A migration opens dozens of staging windows, so a busy hub's 429 or
14512        // 5xx must not end the push — while a refusal that states something
14513        // about the request itself has to surface immediately rather than
14514        // being repeated six times behind a minute of waiting.
14515        for status in [408, 429, 500, 502, 503, 504] {
14516            assert!(is_retryable_hub_status(status), "{status} is load-shaped");
14517        }
14518        for status in [400, 401, 403, 404, 409, 413, 422] {
14519            assert!(
14520                !is_retryable_hub_status(status),
14521                "{status} states something about the request"
14522            );
14523        }
14524        // The waits must reach past a whole limiter window to be worth taking.
14525        let total: u64 = RESERVATION_BACKOFF_MS.iter().sum();
14526        assert!(total >= 60_000, "backoff totals only {total}ms");
14527    }
14528
14529    #[test]
14530    fn a_batch_shares_a_connection_only_within_one_authority() {
14531        // Sharing a pooled agent is what makes a migration stage in minutes,
14532        // but it must never widen where bytes can go: a batch that spans two
14533        // authorities, or names an unsafe URL, falls back to per-object agents
14534        // rather than reusing a connection pinned to somewhere else.
14535        let cfg = HubConfig {
14536            hub: "https://www.sevrahq.com".to_string(),
14537            key: Some("k".to_string()),
14538            agent_key: None,
14539            brain_key: None,
14540            state_dir: PathBuf::from("."),
14541            store_selected: false,
14542        };
14543        assert!(shared_staging_agent(&cfg, &[]).is_none());
14544        assert!(
14545            shared_staging_agent(
14546                &cfg,
14547                &[
14548                    "https://one.example.com/a?sig=1",
14549                    "https://two.example.com/b?sig=2",
14550                ]
14551            )
14552            .is_none(),
14553            "two authorities must not share a pinned pool"
14554        );
14555        assert!(
14556            shared_staging_agent(&cfg, &["http://one.example.com/a"]).is_none(),
14557            "an unsafe object-store URL must not produce an agent"
14558        );
14559        assert!(
14560            shared_staging_agent(&cfg, &["https://user:pw@one.example.com/a"]).is_none(),
14561            "credentials in the URL must not produce an agent"
14562        );
14563    }
14564
14565    #[test]
14566    fn a_staged_change_states_only_operations_and_blobs() {
14567        // The hub refuses a manifest with any other key, so the two sides have
14568        // to agree exactly. A drifting extra field would fail a whole migration
14569        // at the commit, after every byte had already been staged.
14570        let operations = vec![json!({
14571            "op": "put",
14572            "path": "records/a.md",
14573            "blob": "a".repeat(64),
14574            "bytes": 3,
14575        })];
14576        let blobs = json!([{ "sha256": "a".repeat(64), "bytes": 3, "reservation_id": "01" }]);
14577        let bytes = v2_change_manifest(&operations, blobs.clone()).expect("manifest");
14578        let parsed: Value = serde_json::from_slice(&bytes).expect("manifest is JSON");
14579        let keys: Vec<&str> = parsed
14580            .as_object()
14581            .expect("manifest is an object")
14582            .keys()
14583            .map(String::as_str)
14584            .collect();
14585        assert_eq!(keys, ["blobs", "operations"]);
14586        assert_eq!(parsed["operations"], Value::Array(operations));
14587        assert_eq!(parsed["blobs"], blobs);
14588    }
14589
14590    #[test]
14591    fn a_staged_push_signs_the_change_not_the_transport() {
14592        // Self-custody signs a request hash the hub recomputes from the parsed
14593        // change. A staged body no longer states its operations, so the signed
14594        // view has to restore them or every large self-custodied push would
14595        // sign a different request than the one the hub validated.
14596        let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
14597        let staged = json!({
14598            "mutation_id": "dbmd-1",
14599            "rebase": "strict",
14600            "staged_change": { "sha256": "a".repeat(64), "bytes": 9, "reservation_id": "01" },
14601        });
14602        let view = v2_signed_request_view(&staged, &operations);
14603        assert_eq!(view["operations"], Value::Array(operations.clone()));
14604        assert!(view.get("staged_change").is_none());
14605        assert_eq!(view["mutation_id"], staged["mutation_id"]);
14606
14607        let inline = json!({ "mutation_id": "dbmd-1", "operations": operations });
14608        assert_eq!(v2_signed_request_view(&inline, &[]), inline);
14609    }
14610
14611    #[test]
14612    fn a_change_past_the_staging_ceiling_is_refused_before_transport() {
14613        let huge = vec![Value::String("a".repeat(MAX_STAGED_CHANGE_BYTES + 1))];
14614        let error = v2_change_manifest(&huge, Value::Array(Vec::new()))
14615            .expect_err("an oversized change must not be staged");
14616        assert!(
14617            matches!(error, LinkError::PushTooLarge { .. }),
14618            "expected a size refusal, got {error:?}"
14619        );
14620    }
14621
14622    #[test]
14623    fn a_push_that_fits_the_request_is_left_inline() {
14624        // The staged lane costs a reservation and a round trip, so it must
14625        // engage only when the body genuinely does not fit. The hub here is a
14626        // closed port: reaching for it at all would fail this test.
14627        let cfg = HubConfig {
14628            hub: "http://127.0.0.1:9".to_string(),
14629            key: Some("k".to_string()),
14630            agent_key: None,
14631            brain_key: None,
14632            state_dir: PathBuf::from("."),
14633            store_selected: false,
14634        };
14635        let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
14636        let mut body = json!({
14637            "mutation_id": "dbmd-1",
14638            "operations": operations,
14639            "blobs": [],
14640        });
14641        stage_oversized_v2_change(&cfg, "brain", &operations, &mut body).expect("no staging");
14642        assert!(body.get("staged_change").is_none());
14643        assert_eq!(body["operations"], Value::Array(operations));
14644    }
14645
14646    #[test]
14647    fn wide_coordinate_sets_shrink_batches_below_the_count_bound() {
14648        // Byte budget, not just count. One hash can carry many coordinates
14649        // (the same bytes reachable at several paths), so a batch sized only by
14650        // count would blow the body budget on a deduplicated store.
14651        let declarations: Vec<Value> = (0..2_000)
14652            .map(|index| {
14653                json!({
14654                    "sha256": "a".repeat(64),
14655                    "bytes": 10,
14656                    "coordinates": (0..24)
14657                        .map(|slot| format!(
14658                            "records/workflowy-nodes/2019/02/a-fairly-long-node-title-{index}-{slot}-abcd1234.md"
14659                        ))
14660                        .collect::<Vec<_>>(),
14661                })
14662            })
14663            .collect();
14664        let batches = batch_upload_declarations(declarations);
14665        assert!(
14666            batches[0].len() < MAX_UPLOAD_RESERVATION_BLOBS,
14667            "wide coordinate sets must bound the batch by size"
14668        );
14669        for batch in &batches {
14670            let bytes = serde_json::to_string(&json!({ "blobs": batch }))
14671                .expect("batch serializes")
14672                .len();
14673            assert!(bytes <= MAX_UPLOAD_RESERVATION_BYTES + 2_048);
14674        }
14675    }
14676
14677    #[test]
14678    fn a_small_push_still_rides_exactly_one_request() {
14679        let declarations: Vec<Value> = (0..3).map(|i| upload_declaration(i, 8)).collect();
14680        assert_eq!(batch_upload_declarations(declarations).len(), 1);
14681        assert!(batch_upload_declarations(Vec::new()).is_empty());
14682    }
14683
14684    #[test]
14685    fn exact_source_move_becomes_one_provenance_preserving_rename() {
14686        let hash = "a".repeat(64);
14687        let operations = vec![
14688            json!({
14689                "op": "put",
14690                "path": "sources/curated/item.md",
14691                "expected": { "kind": "absent" },
14692                "blob": hash,
14693                "bytes": 19,
14694            }),
14695            json!({
14696                "op": "delete",
14697                "path": "sources/inbox/item.md",
14698                "expected": { "kind": "blob", "hash": hash },
14699            }),
14700        ];
14701
14702        assert_eq!(
14703            infer_exact_source_promotions(operations),
14704            vec![json!({
14705                "op": "rename",
14706                "from": "sources/inbox/item.md",
14707                "to": "sources/curated/item.md",
14708                "expected_from": { "kind": "blob", "hash": hash },
14709                "expected_to": { "kind": "absent" },
14710                "blob": hash,
14711                "bytes": 19,
14712            })]
14713        );
14714    }
14715
14716    #[test]
14717    fn duplicate_source_bytes_never_guess_which_evidence_was_promoted() {
14718        let hash = "b".repeat(64);
14719        let operations = vec![
14720            json!({
14721                "op": "delete",
14722                "path": "sources/inbox/a.md",
14723                "expected": { "kind": "blob", "hash": hash },
14724            }),
14725            json!({
14726                "op": "delete",
14727                "path": "sources/inbox/b.md",
14728                "expected": { "kind": "blob", "hash": hash },
14729            }),
14730            json!({
14731                "op": "put",
14732                "path": "sources/curated/item.md",
14733                "expected": { "kind": "absent" },
14734                "blob": hash,
14735                "bytes": 19,
14736            }),
14737        ];
14738
14739        assert_eq!(
14740            infer_exact_source_promotions(operations.clone()),
14741            operations,
14742            "an ambiguous filesystem diff must reach the hub unchanged and fail closed"
14743        );
14744    }
14745
14746    #[test]
14747    fn accepted_source_promotion_advances_the_local_baseline_exactly() {
14748        let hash = "c".repeat(64);
14749        let mut candidate = std::collections::BTreeMap::from([(
14750            "sources/inbox/item.md".to_string(),
14751            V2BaselineFile {
14752                sha256: hash.clone(),
14753                bytes: 19,
14754                proof: None,
14755            },
14756        )]);
14757        let mut candidate_assets = std::collections::BTreeMap::new();
14758        let operations = vec![
14759            json!({
14760                "op": "rename",
14761                "from": "sources/inbox/item.md",
14762                "to": "sources/curated/item.md",
14763                "expected_from": { "kind": "blob", "hash": hash },
14764                "expected_to": { "kind": "absent" },
14765                "blob": hash,
14766                "bytes": 19,
14767            }),
14768            json!({
14769                "op": "put",
14770                "path": "records/rsvps/item.md",
14771                "expected": { "kind": "absent" },
14772                "blob": "d".repeat(64),
14773                "bytes": 23,
14774            }),
14775        ];
14776
14777        assert!(!apply_generated_v2_operations(
14778            &operations,
14779            &std::collections::BTreeMap::new(),
14780            &mut candidate,
14781            &mut candidate_assets,
14782        )
14783        .unwrap());
14784        assert!(!candidate.contains_key("sources/inbox/item.md"));
14785        assert_eq!(
14786            candidate
14787                .get("sources/curated/item.md")
14788                .map(|file| (&file.sha256, file.bytes)),
14789            Some((&hash, 19))
14790        );
14791        assert_eq!(
14792            candidate
14793                .get("records/rsvps/item.md")
14794                .map(|file| (file.sha256.as_str(), file.bytes)),
14795            Some((
14796                "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
14797                23
14798            ))
14799        );
14800    }
14801
14802    fn merge_fixture(
14803        base: Option<&str>,
14804        remote: Option<&str>,
14805        local: Option<&str>,
14806        keep_local: bool,
14807    ) -> V2PulledMerge<String> {
14808        let map = |value: Option<&str>| {
14809            value
14810                .map(|value| [("records/a.md".to_string(), value.to_string())])
14811                .into_iter()
14812                .flatten()
14813                .collect::<std::collections::BTreeMap<_, _>>()
14814        };
14815        merge_v2_pulled_records(
14816            &map(base),
14817            &map(remote),
14818            &map(local),
14819            |value, _| value.clone(),
14820            |value, _| value.clone(),
14821            |_| keep_local,
14822        )
14823    }
14824
14825    #[test]
14826    fn v2_pull_three_way_merge_never_discards_a_local_only_change() {
14827        let path = "records/a.md".to_string();
14828
14829        let local_add = merge_fixture(None, None, Some("local"), false);
14830        assert_eq!(
14831            local_add.records.get(&path).map(String::as_str),
14832            Some("local")
14833        );
14834        assert!(local_add.accept_remote.is_empty());
14835        assert!(local_add.conflicts.is_empty());
14836
14837        let local_edit = merge_fixture(Some("old"), Some("old"), Some("local"), false);
14838        assert_eq!(
14839            local_edit.records.get(&path).map(String::as_str),
14840            Some("local")
14841        );
14842        assert!(local_edit.accept_remote.is_empty());
14843        assert!(local_edit.conflicts.is_empty());
14844
14845        let local_delete = merge_fixture(Some("old"), Some("old"), None, false);
14846        assert!(!local_delete.records.contains_key(&path));
14847        assert!(local_delete.accept_remote.is_empty());
14848        assert!(local_delete.conflicts.is_empty());
14849
14850        let remote_edit = merge_fixture(Some("old"), Some("remote"), Some("old"), false);
14851        assert_eq!(
14852            remote_edit.records.get(&path).map(String::as_str),
14853            Some("remote")
14854        );
14855        assert!(remote_edit.accept_remote.contains(&path));
14856        assert!(remote_edit.conflicts.is_empty());
14857
14858        let remote_delete = merge_fixture(Some("old"), None, Some("old"), false);
14859        assert!(!remote_delete.records.contains_key(&path));
14860        assert!(remote_delete.accept_remote.contains(&path));
14861        assert!(remote_delete.conflicts.is_empty());
14862
14863        let same_edit = merge_fixture(Some("old"), Some("same"), Some("same"), false);
14864        assert_eq!(
14865            same_edit.records.get(&path).map(String::as_str),
14866            Some("same")
14867        );
14868        assert!(same_edit.accept_remote.contains(&path));
14869        assert!(same_edit.conflicts.is_empty());
14870
14871        let conflict = merge_fixture(Some("old"), Some("remote"), Some("local"), false);
14872        assert_eq!(conflict.conflicts, vec![path.clone()]);
14873        assert_eq!(
14874            conflict.records.get(&path).map(String::as_str),
14875            Some("local")
14876        );
14877        assert!(conflict.accept_remote.is_empty());
14878
14879        let kept_home = merge_fixture(Some("old"), Some("remote"), Some("local"), true);
14880        assert_eq!(
14881            kept_home.records.get(&path).map(String::as_str),
14882            Some("local")
14883        );
14884        assert!(kept_home.accept_remote.is_empty());
14885        assert!(kept_home.conflicts.is_empty());
14886    }
14887
14888    #[test]
14889    fn v2_asset_hosting_resume_is_an_explicit_local_policy_transition() {
14890        let path = "sources/report.pdf";
14891        let record = crate::AssetRecord {
14892            path: path.to_string(),
14893            sha256: "a".repeat(64),
14894            bytes: 42,
14895            media_type: "application/pdf".to_string(),
14896            wrappers: vec!["gzip".to_string()],
14897            required: true,
14898        };
14899        let mut remote = V2BaselineAsset {
14900            blob_sha256: record.sha256.clone(),
14901            bytes: record.bytes,
14902            media_type: record.media_type.clone(),
14903            wrappers: record.wrappers.clone(),
14904            required: record.required,
14905            disposition: "withheld".to_string(),
14906            leaf_hash: "b".repeat(64),
14907        };
14908
14909        assert!(v2_asset_resumes_hosting(
14910            Some(&remote),
14911            path,
14912            &record,
14913            "hosted"
14914        ));
14915        assert!(!v2_asset_resumes_hosting(
14916            Some(&remote),
14917            path,
14918            &record,
14919            "withheld"
14920        ));
14921
14922        remote.disposition = "hosted".to_string();
14923        assert!(!v2_asset_resumes_hosting(
14924            Some(&remote),
14925            path,
14926            &record,
14927            "hosted"
14928        ));
14929
14930        remote.disposition = "withheld".to_string();
14931        remote.blob_sha256 = "c".repeat(64);
14932        assert!(!v2_asset_resumes_hosting(
14933            Some(&remote),
14934            path,
14935            &record,
14936            "hosted"
14937        ));
14938        assert!(!v2_asset_resumes_hosting(None, path, &record, "hosted"));
14939    }
14940
14941    #[test]
14942    fn take_remote_resolution_rebinds_to_the_exact_current_manifest() {
14943        let path = "records/team/alpha.md".to_string();
14944        let deleted_path = "records/team/deleted.md".to_string();
14945        let coordinate = |sha256: Option<String>, bytes: Option<u64>| V2ConflictCoordinate {
14946            sha256,
14947            bytes,
14948            file: None,
14949        };
14950        let files = vec![
14951            V2ConflictFile {
14952                path: path.clone(),
14953                base: coordinate(None, None),
14954                local: coordinate(Some("b".repeat(64)), Some(7)),
14955                remote: coordinate(Some("a".repeat(64)), Some(5)),
14956            },
14957            V2ConflictFile {
14958                path: deleted_path.clone(),
14959                base: coordinate(Some("c".repeat(64)), Some(9)),
14960                local: coordinate(Some("d".repeat(64)), Some(11)),
14961                remote: coordinate(None, None),
14962            },
14963        ];
14964        let proven = V2BaselineFile {
14965            sha256: "a".repeat(64),
14966            bytes: 5,
14967            proof: None,
14968        };
14969        let current = [(path.clone(), proven.clone())]
14970            .into_iter()
14971            .collect::<std::collections::BTreeMap<_, _>>();
14972
14973        let (selected, deleted) = v2_take_remote_selection(&files, &current).unwrap();
14974        assert_eq!(selected.get(&path).unwrap().sha256, proven.sha256);
14975        assert_eq!(deleted, vec![deleted_path.clone()]);
14976
14977        let changed = [(
14978            path.clone(),
14979            V2BaselineFile {
14980                sha256: "e".repeat(64),
14981                bytes: 5,
14982                proof: None,
14983            },
14984        )]
14985        .into_iter()
14986        .collect::<std::collections::BTreeMap<_, _>>();
14987        assert!(v2_take_remote_selection(&files, &changed).is_err());
14988
14989        let resurrected = [
14990            (path, proven),
14991            (
14992                deleted_path,
14993                V2BaselineFile {
14994                    sha256: "f".repeat(64),
14995                    bytes: 13,
14996                    proof: None,
14997                },
14998            ),
14999        ]
15000        .into_iter()
15001        .collect::<std::collections::BTreeMap<_, _>>();
15002        assert!(v2_take_remote_selection(&files, &resurrected).is_err());
15003    }
15004
15005    #[cfg(target_os = "linux")]
15006    #[test]
15007    fn linux_snapshot_install_is_atomic_for_create_and_exchange() {
15008        use std::os::fd::AsRawFd as _;
15009
15010        let sandbox = tempfile::TempDir::new().unwrap();
15011        let parent = std::fs::File::open(sandbox.path()).unwrap();
15012        let stage = std::ffi::CString::new("stage").unwrap();
15013        let destination = std::ffi::CString::new("brain").unwrap();
15014
15015        std::fs::create_dir(sandbox.path().join("stage")).unwrap();
15016        std::fs::write(sandbox.path().join("stage/value"), b"created").unwrap();
15017        install_stage_at(
15018            parent.as_raw_fd(),
15019            stage.as_c_str(),
15020            destination.as_c_str(),
15021            false,
15022        )
15023        .unwrap();
15024        assert!(!sandbox.path().join("stage").exists());
15025        assert_eq!(
15026            std::fs::read(sandbox.path().join("brain/value")).unwrap(),
15027            b"created"
15028        );
15029
15030        std::fs::create_dir(sandbox.path().join("stage")).unwrap();
15031        std::fs::write(sandbox.path().join("stage/value"), b"replacement").unwrap();
15032        install_stage_at(
15033            parent.as_raw_fd(),
15034            stage.as_c_str(),
15035            destination.as_c_str(),
15036            true,
15037        )
15038        .unwrap();
15039        assert_eq!(
15040            std::fs::read(sandbox.path().join("brain/value")).unwrap(),
15041            b"replacement"
15042        );
15043        assert_eq!(
15044            std::fs::read(sandbox.path().join("stage/value")).unwrap(),
15045            b"created",
15046            "RENAME_EXCHANGE must leave the predecessor at the unique stage name"
15047        );
15048    }
15049
15050    struct SignedRemoteFixture {
15051        card: String,
15052        feed: String,
15053        key: AgentSigningKey,
15054        identity: FeedIdentity,
15055    }
15056
15057    fn signed_remote_fixture() -> SignedRemoteFixture {
15058        let rng = ring::rand::SystemRandom::new();
15059        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15060        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15061        let (public_key, multikey) = public_identity_for(&pair);
15062        let identity = FeedIdentity {
15063            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
15064            public_key_spki: public_key.clone(),
15065            previous: Vec::new(),
15066            rotations: Vec::new(),
15067        };
15068        let mut entry = FeedEntry {
15069            v: 1,
15070            seq: 1,
15071            ts: "2026-07-30T12:00:00.000Z".to_string(),
15072            brain: multikey.clone(),
15073            public_key: public_key.clone(),
15074            kind: "push".to_string(),
15075            op: "snapshot".to_string(),
15076            pack_sha256: "a".repeat(64),
15077            files: Vec::new(),
15078            removed: Vec::new(),
15079            prev_entry_hash: None,
15080            sig: String::new(),
15081        };
15082        let unsigned = UnsignedFeedEntry {
15083            v: entry.v,
15084            seq: entry.seq,
15085            ts: &entry.ts,
15086            brain: &entry.brain,
15087            public_key: &entry.public_key,
15088            kind: &entry.kind,
15089            op: &entry.op,
15090            pack_sha256: &entry.pack_sha256,
15091            files: &entry.files,
15092            removed: &entry.removed,
15093            prev_entry_hash: &entry.prev_entry_hash,
15094        };
15095        entry.sig =
15096            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
15097        let mut exact = serde_json::to_vec(&entry).unwrap();
15098        exact.push(b'\n');
15099        let hash = content_sha256(&exact);
15100        let card = json!({
15101            "id": TEST_BRAIN_ID,
15102            "headSeq": 1,
15103            "feedHash": hash,
15104            "identity": identity.clone(),
15105        })
15106        .to_string();
15107        let feed = json!({
15108            "headSeq": 1,
15109            "feedHash": hash,
15110            "identity": identity.clone(),
15111            "entries": [{"hash": hash, "entry": entry}],
15112            "scopeLimited": false,
15113        })
15114        .to_string();
15115        SignedRemoteFixture {
15116            card,
15117            feed,
15118            key: AgentSigningKey {
15119                pkcs8: pkcs8.as_ref().to_vec(),
15120                multikey,
15121                public_key_spki: public_key,
15122            },
15123            identity,
15124        }
15125    }
15126
15127    #[test]
15128    fn v1_edit_disclosure_accepts_minimal_and_superset_forms() {
15129        let file = |path: &str, byte: char| FeedFile {
15130            path: path.to_string(),
15131            sha256: byte.to_string().repeat(64),
15132            bytes: 1,
15133        };
15134        let a0 = file("records/a.md", 'a');
15135        let a1 = file("records/a.md", 'b');
15136        let stable = file("records/stable.md", 'c');
15137        let added = file("records/added.md", 'd');
15138        let removed_file = file("records/removed.md", 'e');
15139        let previous = vec![a0, stable.clone(), removed_file.clone()];
15140        let resulting = vec![a1.clone(), stable.clone(), added.clone()];
15141        let removed = vec![removed_file.path.clone()];
15142
15143        assert_eq!(
15144            verify_v1_manifest_disclosure(
15145                "edit",
15146                &previous,
15147                &resulting,
15148                &[a1.clone(), added.clone()],
15149                &removed,
15150            ),
15151            Ok(())
15152        );
15153        assert_eq!(
15154            verify_v1_manifest_disclosure(
15155                "edit",
15156                &previous,
15157                &resulting,
15158                &[stable.clone(), added.clone(), a1.clone()],
15159                &removed,
15160            ),
15161            Ok(())
15162        );
15163        assert_eq!(
15164            verify_v1_manifest_disclosure(
15165                "edit",
15166                &previous,
15167                &resulting,
15168                std::slice::from_ref(&added),
15169                &removed,
15170            ),
15171            Err(V1DisclosureError::EditMissingChange)
15172        );
15173        assert_eq!(
15174            verify_v1_manifest_disclosure(
15175                "edit",
15176                &previous,
15177                &resulting,
15178                &[file("records/a.md", 'f'), added.clone()],
15179                &removed,
15180            ),
15181            Err(V1DisclosureError::EditFalseFile)
15182        );
15183        assert_eq!(
15184            verify_v1_manifest_disclosure(
15185                "edit",
15186                &previous,
15187                &resulting,
15188                &[a1.clone(), added.clone()],
15189                &[],
15190            ),
15191            Err(V1DisclosureError::RemovedMismatch)
15192        );
15193        assert_eq!(
15194            verify_v1_manifest_disclosure(
15195                "push",
15196                &previous,
15197                &resulting,
15198                &[added.clone(), stable, a1],
15199                &removed,
15200            ),
15201            Ok(())
15202        );
15203        assert_eq!(
15204            verify_v1_manifest_disclosure("push", &previous, &resulting, &[added], &removed,),
15205            Err(V1DisclosureError::PushManifestMismatch)
15206        );
15207    }
15208
15209    #[test]
15210    fn wire_sequences_are_rejected_at_their_endpoint_count_limits() {
15211        let fixture = signed_remote_fixture();
15212        let feed: Value = serde_json::from_str(&fixture.feed).unwrap();
15213        let item = feed["entries"][0].to_string();
15214        let oversized_page = format!(
15215            "{{\"headSeq\":1,\"feedHash\":null,\"identity\":null,\"entries\":[{}],\"scopeLimited\":false}}",
15216            std::iter::repeat_n(item.as_str(), FEED_PAGE_LIMIT + 1)
15217                .collect::<Vec<_>>()
15218                .join(",")
15219        );
15220        assert!(serde_json::from_str::<FeedResponse>(&oversized_page).is_err());
15221
15222        let oversized_identity = format!(
15223            "{{\"fingerprint\":\"fp\",\"publicKeySpki\":\"spki\",\"previous\":[],\"rotations\":[{}]}}",
15224            std::iter::repeat_n("\"rotation\"", MAX_IDENTITY_ROTATIONS + 1)
15225                .collect::<Vec<_>>()
15226                .join(",")
15227        );
15228        assert!(serde_json::from_str::<FeedIdentity>(&oversized_identity).is_err());
15229
15230        let file = r#"{"path":"a","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bytes":1}"#;
15231        let oversized_entry = format!(
15232            "{{\"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\"}}",
15233            "a".repeat(64),
15234            std::iter::repeat_n(file, MAX_PUSH_FILES + 1)
15235                .collect::<Vec<_>>()
15236                .join(",")
15237        );
15238        assert!(serde_json::from_str::<FeedEntry>(&oversized_entry).is_err());
15239    }
15240
15241    #[test]
15242    fn bulk_confirmation_parser_accepts_only_the_exact_wire_shape() {
15243        let id = "01arz3ndektsv4rrffq69g5fav";
15244        let digest = "a".repeat(64);
15245        assert_eq!(
15246            V2BulkConfirmation::parse(&format!("{id}:{digest}")).unwrap(),
15247            V2BulkConfirmation {
15248                id: id.to_string(),
15249                digest,
15250            }
15251        );
15252        for invalid in [
15253            "",
15254            "01arz3ndektsv4rrffq69g5fav",
15255            "01ARZ3NDEKTSV4RRFFQ69G5FAV:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
15256            "01arz3ndektsv4rrffq69g5fav:ABCDEF",
15257            "01arz3ndektsv4rrffq69g5fav:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
15258        ] {
15259            assert!(matches!(
15260                V2BulkConfirmation::parse(invalid),
15261                Err(LinkError::InvalidPack { .. })
15262            ));
15263        }
15264    }
15265
15266    fn scripted_json_hub(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle<()>) {
15267        use std::io::{BufRead as _, BufReader, Read as _, Write as _};
15268        use std::net::TcpListener;
15269
15270        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15271        let url = format!("http://{}", listener.local_addr().unwrap());
15272        let handle = std::thread::spawn(move || {
15273            for (status, body) in responses {
15274                let (stream, _) = listener.accept().unwrap();
15275                let mut reader = BufReader::new(stream);
15276                let mut line = String::new();
15277                reader.read_line(&mut line).unwrap();
15278                let mut content_length = 0usize;
15279                loop {
15280                    line.clear();
15281                    reader.read_line(&mut line).unwrap();
15282                    if line == "\r\n" || line == "\n" || line.is_empty() {
15283                        break;
15284                    }
15285                    if let Some((name, value)) = line.split_once(':') {
15286                        if name.eq_ignore_ascii_case("content-length") {
15287                            content_length = value.trim().parse().unwrap();
15288                        }
15289                    }
15290                }
15291                let mut request_body = vec![0_u8; content_length];
15292                reader.read_exact(&mut request_body).unwrap();
15293                let response = format!(
15294                    "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
15295                    body.len()
15296                );
15297                reader.get_mut().write_all(response.as_bytes()).unwrap();
15298            }
15299        });
15300        (url, handle)
15301    }
15302
15303    fn routed_json_hub(
15304        requests: usize,
15305        mut respond: impl FnMut(&str) -> (u16, String) + Send + 'static,
15306    ) -> (String, std::thread::JoinHandle<()>) {
15307        use std::io::{BufRead as _, BufReader, Read as _, Write as _};
15308        use std::net::TcpListener;
15309
15310        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15311        let url = format!("http://{}", listener.local_addr().unwrap());
15312        let handle = std::thread::spawn(move || {
15313            for _ in 0..requests {
15314                let (stream, _) = listener.accept().unwrap();
15315                let mut reader = BufReader::new(stream);
15316                let mut line = String::new();
15317                reader.read_line(&mut line).unwrap();
15318                let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
15319                let mut content_length = 0usize;
15320                loop {
15321                    line.clear();
15322                    reader.read_line(&mut line).unwrap();
15323                    if line == "\r\n" || line == "\n" || line.is_empty() {
15324                        break;
15325                    }
15326                    if let Some((name, value)) = line.split_once(':') {
15327                        if name.eq_ignore_ascii_case("content-length") {
15328                            content_length = value.trim().parse().unwrap();
15329                        }
15330                    }
15331                }
15332                let mut request_body = vec![0_u8; content_length];
15333                reader.read_exact(&mut request_body).unwrap();
15334                let (status, body) = respond(&path);
15335                let response = format!(
15336                    "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
15337                    body.len()
15338                );
15339                reader.get_mut().write_all(response.as_bytes()).unwrap();
15340            }
15341        });
15342        (url, handle)
15343    }
15344
15345    fn test_hub_config(hub: String, state_dir: PathBuf) -> HubConfig {
15346        HubConfig {
15347            hub,
15348            key: Some("test-key".to_string()),
15349            agent_key: None,
15350            brain_key: None,
15351            state_dir,
15352            store_selected: false,
15353        }
15354    }
15355
15356    #[test]
15357    fn linkmd_sig_v2_is_bound_to_the_exact_hub_origin() {
15358        use ring::signature::KeyPair as _;
15359
15360        let rng = ring::rand::SystemRandom::new();
15361        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15362        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15363        let (spki, multikey) = public_identity_for(&pair);
15364        let key = AgentSigningKey {
15365            pkcs8: pkcs8.as_ref().to_vec(),
15366            multikey,
15367            public_key_spki: spki,
15368        };
15369        let header = linkmd_sig_header(
15370            &key,
15371            "https://hub-a.example",
15372            "post",
15373            "/api/hub/brains/brain/push?mode=exact",
15374            Some("{\"ok\":true}"),
15375        )
15376        .unwrap();
15377        assert!(header.starts_with("LinkMD-Sig v2,"));
15378        let ts = header
15379            .split(",ts=")
15380            .nth(1)
15381            .unwrap()
15382            .split(',')
15383            .next()
15384            .unwrap();
15385        let signature = URL_SAFE_NO_PAD
15386            .decode(header.rsplit(",sig=").next().unwrap())
15387            .unwrap();
15388        let body_hash = format!("{:x}", Sha256::digest(b"{\"ok\":true}"));
15389        let accepted = format!(
15390            "v2\nhttps://hub-a.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
15391        );
15392        let replayed = format!(
15393            "v2\nhttps://hub-b.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
15394        );
15395        let public = pair.public_key().as_ref();
15396        assert!(UnparsedPublicKey::new(&ED25519, public)
15397            .verify(accepted.as_bytes(), &signature)
15398            .is_ok());
15399        assert!(
15400            UnparsedPublicKey::new(&ED25519, public)
15401                .verify(replayed.as_bytes(), &signature)
15402                .is_err(),
15403            "a proof captured at hub A must not authenticate at hub B"
15404        );
15405    }
15406
15407    #[test]
15408    fn explicit_brain_id_rejects_a_substituted_card_before_feed_trust() {
15409        let other = "01j5qc3v9k4ym8rwbn2tqe6f7e";
15410        let card = json!({
15411            "id": other,
15412            "headSeq": 0,
15413            "identity": signed_remote_fixture().identity,
15414        })
15415        .to_string();
15416        let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
15417        let state = tempfile::tempdir().unwrap();
15418        let cfg = test_hub_config(hub, state.path().to_path_buf());
15419        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15420        assert!(
15421            error.contains("differs from the explicitly requested"),
15422            "{error}"
15423        );
15424        server.join().unwrap();
15425    }
15426
15427    #[test]
15428    fn empty_brain_identity_is_pinned_and_cannot_be_substituted_later() {
15429        let first = signed_remote_fixture().identity;
15430        let second = signed_remote_fixture().identity;
15431        let card = |identity: FeedIdentity| {
15432            json!({
15433                "id": TEST_BRAIN_ID,
15434                "headSeq": 0,
15435                "identity": identity,
15436            })
15437            .to_string()
15438        };
15439        let (hub, server) = scripted_json_hub(vec![
15440            (404, "{}".to_string()),
15441            (200, card(first)),
15442            (404, "{}".to_string()),
15443            (200, card(second)),
15444        ]);
15445        let state = tempfile::tempdir().unwrap();
15446        let cfg = test_hub_config(hub, state.path().to_path_buf());
15447        assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
15448        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15449        assert!(
15450            error.contains("pinned anchor") || error.contains("forked away"),
15451            "{error}"
15452        );
15453        server.join().unwrap();
15454    }
15455
15456    #[test]
15457    fn empty_head_rejects_a_rotation_that_commits_to_hidden_history() {
15458        let old = signed_remote_fixture();
15459        let new = signed_remote_fixture();
15460        let old_pair = ring::signature::Ed25519KeyPair::from_pkcs8(&old.key.pkcs8).unwrap();
15461        let unsigned = serde_json::to_string(&UnsignedRotation {
15462            v: 1,
15463            op: "rotate",
15464            brain: &old.key.multikey,
15465            public_key: &old.key.public_key_spki,
15466            new_brain: &new.key.multikey,
15467            new_public_key: &new.key.public_key_spki,
15468            prior_head_seq: 1,
15469            prior_feed_hash: Some(&"a".repeat(64)),
15470            ts: "2026-07-30T12:00:00.000Z".to_string(),
15471        })
15472        .unwrap();
15473        let signature = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
15474        let rotation = format!(
15475            "{},\"sig\":\"{}\"}}",
15476            &unsigned[..unsigned.len() - 1],
15477            signature
15478        );
15479        let identity = FeedIdentity {
15480            fingerprint: new.key.multikey.trim_start_matches("ed25519:").to_string(),
15481            public_key_spki: new.key.public_key_spki,
15482            previous: vec![PreviousIdentity {
15483                fingerprint: old.key.multikey.trim_start_matches("ed25519:").to_string(),
15484                public_key_spki: old.key.public_key_spki,
15485            }],
15486            rotations: vec![rotation],
15487        };
15488        let card = json!({
15489            "id": TEST_BRAIN_ID,
15490            "headSeq": 0,
15491            "feedHash": null,
15492            "identity": identity,
15493        })
15494        .to_string();
15495        let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
15496        let state = tempfile::tempdir().unwrap();
15497        let cfg = test_hub_config(hub, state.path().to_path_buf());
15498        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15499        assert!(
15500            error.contains("rotation claims a feed boundary beyond the advertised head"),
15501            "{error}"
15502        );
15503        assert!(
15504            load_trust(&cfg, TEST_BRAIN_ID).unwrap().is_none(),
15505            "an inconsistent empty-head identity must not become the TOFU checkpoint"
15506        );
15507        server.join().unwrap();
15508    }
15509
15510    #[test]
15511    fn trust_checkpoint_rejects_a_later_fork() {
15512        let fixture = signed_remote_fixture();
15513        let mut fork: Value = serde_json::from_str(&fixture.card).unwrap();
15514        fork["feedHash"] = Value::String("b".repeat(64));
15515        let (hub, server) = scripted_json_hub(vec![
15516            (404, "{}".to_string()),
15517            (200, fixture.card),
15518            (200, fixture.feed),
15519            (404, "{}".to_string()),
15520            (200, fork.to_string()),
15521        ]);
15522        let state = tempfile::tempdir().unwrap();
15523        let cfg = test_hub_config(hub, state.path().to_path_buf());
15524        assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
15525        assert!(head(&cfg, TEST_BRAIN_ID).is_err());
15526        server.join().unwrap();
15527    }
15528
15529    #[test]
15530    fn alias_and_canonical_id_share_one_identity_checkpoint() {
15531        let trusted = signed_remote_fixture();
15532        let attacker = signed_remote_fixture();
15533        let (hub, server) = scripted_json_hub(vec![
15534            (404, "{}".to_string()),
15535            (200, trusted.card),
15536            (200, trusted.feed),
15537            (404, "{}".to_string()),
15538            (200, attacker.card),
15539        ]);
15540        let state = tempfile::tempdir().unwrap();
15541        let cfg = test_hub_config(hub, state.path().to_path_buf());
15542        assert!(head(&cfg, "trusted-slug").unwrap().verified);
15543        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15544        assert!(
15545            error.contains("equivocation")
15546                || error.contains("pinned")
15547                || error.contains("identity"),
15548            "{error}"
15549        );
15550        server.join().unwrap();
15551    }
15552
15553    #[test]
15554    fn moved_alias_requires_exact_explicit_rebind_without_rewriting_history() {
15555        let state = tempfile::tempdir().unwrap();
15556        let cfg = test_hub_config(
15557            "https://hub.example".to_string(),
15558            state.path().to_path_buf(),
15559        );
15560        let directory = open_trust_dir(&cfg).unwrap();
15561        let old = TEST_BRAIN_ID;
15562        let new = "01j5qc3v9k4ym8rwbn2tqe6f7e";
15563        save_alias_in(
15564            &cfg,
15565            &directory,
15566            &AliasBinding {
15567                v: 1,
15568                origin: normalized_origin(&cfg.hub).unwrap(),
15569                requested: "company-brain".to_string(),
15570                brain: old.to_string(),
15571                home: Some("company-brain".to_string()),
15572            },
15573        )
15574        .unwrap();
15575
15576        let error = load_canonical_pin(&cfg, &directory, "company-brain", new).unwrap_err();
15577        assert!(matches!(
15578            error,
15579            LinkError::AliasRebindRequired {
15580                alias,
15581                from,
15582                to
15583            } if alias == "company-brain" && from == old && to == new
15584        ));
15585        let unchanged = load_alias_in(&cfg, &directory, "company-brain")
15586            .unwrap()
15587            .unwrap();
15588        assert_eq!(unchanged.brain, old);
15589        assert_eq!(unchanged.home.as_deref(), Some("company-brain"));
15590    }
15591
15592    #[test]
15593    fn concurrent_aliases_cannot_establish_conflicting_tofu_pins() {
15594        let alpha = signed_remote_fixture();
15595        let beta = signed_remote_fixture();
15596        let alpha_card = alpha.card.clone();
15597        let alpha_feed = alpha.feed.clone();
15598        let beta_card = beta.card.clone();
15599        let beta_feed = beta.feed.clone();
15600        let (hub, server) = routed_json_hub(5, move |path| {
15601            if path.ends_with("/v2/head") {
15602                (404, "{}".to_string())
15603            } else if path.contains("/alpha/feed?") {
15604                (200, alpha_feed.clone())
15605            } else if path.contains("/beta/feed?") {
15606                (200, beta_feed.clone())
15607            } else if path.ends_with("/alpha") {
15608                (200, alpha_card.clone())
15609            } else if path.ends_with("/beta") {
15610                (200, beta_card.clone())
15611            } else {
15612                (500, r#"{"error":"unexpected path"}"#.to_string())
15613            }
15614        });
15615        let state = tempfile::tempdir().unwrap();
15616        let cfg = test_hub_config(hub, state.path().to_path_buf());
15617        let alpha_cfg = cfg.clone();
15618        let beta_cfg = cfg;
15619        let first = std::thread::spawn(move || head(&alpha_cfg, "alpha"));
15620        let second = std::thread::spawn(move || head(&beta_cfg, "beta"));
15621        let results = [first.join().unwrap(), second.join().unwrap()];
15622        assert_eq!(
15623            results.iter().filter(|result| result.is_ok()).count(),
15624            1,
15625            "only one alias identity may establish canonical TOFU: {results:?}"
15626        );
15627        assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
15628        server.join().unwrap();
15629    }
15630
15631    #[cfg(unix)]
15632    #[test]
15633    fn trust_transaction_survives_an_ancestor_swap_without_writing_outside() {
15634        use std::os::unix::fs::symlink;
15635
15636        let fixture = signed_remote_fixture();
15637        let card = json!({
15638            "id": TEST_BRAIN_ID,
15639            "headSeq": 0,
15640            "feedHash": Value::Null,
15641            "identity": fixture.identity,
15642        })
15643        .to_string();
15644        let work = tempfile::tempdir().unwrap();
15645        let outside = tempfile::tempdir().unwrap();
15646        let state = work.path().join("state");
15647        let moved = work.path().join("state-held");
15648        let swap_state = state.clone();
15649        let swap_moved = moved.clone();
15650        let outside_path = outside.path().to_path_buf();
15651        let (hub, server) = routed_json_hub(1, move |_| {
15652            // The client has already opened state/trust before this response.
15653            std::fs::rename(&swap_state, &swap_moved).unwrap();
15654            symlink(&outside_path, &swap_state).unwrap();
15655            (200, card.clone())
15656        });
15657        let cfg = test_hub_config(hub, state);
15658
15659        let verified = verified_remote_head(&cfg, TEST_BRAIN_ID, false).unwrap();
15660        assert_eq!(verified.head.seq, 0);
15661        assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
15662        assert!(std::fs::read_dir(moved.join("trust"))
15663            .unwrap()
15664            .flatten()
15665            .any(|entry| entry.path().extension().is_some_and(|ext| ext == "json")));
15666        server.join().unwrap();
15667    }
15668
15669    #[test]
15670    fn self_custody_push_refuses_an_unrelated_key_before_requesting_an_upload() {
15671        let remote = signed_remote_fixture();
15672        let unrelated = signed_remote_fixture().key;
15673        let (hub, server) = scripted_json_hub(vec![(200, remote.card), (200, remote.feed)]);
15674        let state = tempfile::tempdir().unwrap();
15675        let mut cfg = test_hub_config(hub, state.path().to_path_buf());
15676        cfg.brain_key = Some(unrelated);
15677        let error = sync_push(
15678            &cfg,
15679            TEST_BRAIN_ID,
15680            &[("DB.md".to_string(), "signed local content".to_string())],
15681        )
15682        .unwrap_err()
15683        .to_string();
15684        assert!(
15685            error.contains("not the verified current brain identity"),
15686            "{error}"
15687        );
15688        server.join().unwrap();
15689    }
15690
15691    #[test]
15692    fn rotation_ignores_a_forged_2xx_body_and_requires_verified_postcondition() {
15693        let remote = signed_remote_fixture();
15694        let new = signed_remote_fixture().key;
15695        let state = tempfile::tempdir().unwrap();
15696        let new_file = state.path().join("new.key");
15697        std::fs::write(
15698            &new_file,
15699            format!("{}\n", URL_SAFE_NO_PAD.encode(&new.pkcs8)),
15700        )
15701        .unwrap();
15702        #[cfg(unix)]
15703        {
15704            use std::os::unix::fs::PermissionsExt as _;
15705            std::fs::set_permissions(&new_file, std::fs::Permissions::from_mode(0o600)).unwrap();
15706        }
15707        let forged = json!({
15708            "brain": TEST_BRAIN_ID,
15709            "identity": {
15710                "fingerprint": new.multikey.trim_start_matches("ed25519:"),
15711                "publicKeySpki": new.public_key_spki,
15712            }
15713        })
15714        .to_string();
15715        let (hub, server) = scripted_json_hub(vec![
15716            (404, "{}".to_string()),
15717            (200, remote.card.clone()),
15718            (200, remote.feed.clone()),
15719            (200, forged),
15720            (200, remote.card),
15721            (200, remote.feed),
15722        ]);
15723        let cfg = test_hub_config(hub, state.path().to_path_buf());
15724        let error = rotate_brain_key(&cfg, TEST_BRAIN_ID, &remote.key, &new_file)
15725            .unwrap_err()
15726            .to_string();
15727        assert!(
15728            error.contains("without committing the verified new identity"),
15729            "{error}"
15730        );
15731        server.join().unwrap();
15732    }
15733
15734    #[test]
15735    fn resolve_record_is_derived_from_signed_pack_not_a_query_response() {
15736        let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
15737        let raw = format!(
15738            "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
15739        );
15740        let pack = build_store_pack(&[
15741            (
15742                "DB.md".to_string(),
15743                "---\ntype: db-md\nscope: company\nowner: Test\n---\n".to_string(),
15744            ),
15745            ("records/clients/truth.md".to_string(), raw.clone()),
15746        ])
15747        .unwrap();
15748        let by_id = resolve_from_verified_pack(
15749            "01j5qc3v9k4ym8rwbn2tqe6f7d",
15750            &AddressTarget::Id(record_id.to_string()),
15751            pack.clone(),
15752        )
15753        .unwrap();
15754        assert_eq!(by_id["document"]["summary"], "Signed truth");
15755        assert_eq!(by_id["document"]["body"], "# Signed truth\n");
15756        assert_eq!(
15757            by_id["document"]["contentSha"],
15758            content_sha256(raw.as_bytes())
15759        );
15760
15761        let by_path = resolve_from_verified_pack(
15762            "01j5qc3v9k4ym8rwbn2tqe6f7d",
15763            &AddressTarget::Path("records/clients/truth.md".to_string()),
15764            pack,
15765        )
15766        .unwrap();
15767        assert_eq!(by_path["document"]["id"], record_id);
15768        assert_eq!(by_path["document"]["path"], "records/clients/truth.md");
15769
15770        let wrong_id = resolve_from_verified_record_bytes(
15771            TEST_BRAIN_ID,
15772            &AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7f".to_string()),
15773            "records/clients/truth.md".to_string(),
15774            raw.as_bytes().to_vec(),
15775        )
15776        .unwrap_err()
15777        .to_string();
15778        assert!(wrong_id.contains("id differs"), "{wrong_id}");
15779
15780        let wrong_path = resolve_from_verified_record_bytes(
15781            TEST_BRAIN_ID,
15782            &AddressTarget::Path("records/clients/other.md".to_string()),
15783            "records/clients/truth.md".to_string(),
15784            raw.into_bytes(),
15785        )
15786        .unwrap_err()
15787        .to_string();
15788        assert!(wrong_path.contains("path differs"), "{wrong_path}");
15789    }
15790
15791    #[test]
15792    fn v2_record_locators_return_one_proof_bound_to_the_signed_content_root() {
15793        let path = "records/clients/truth.md";
15794        let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
15795        let raw = format!(
15796            "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
15797        );
15798        let sha256 = content_sha256(raw.as_bytes());
15799        let mut nonce = 0_u128;
15800        let tree = crate::linkmd_v2::build_content_tree(
15801            &[crate::linkmd_v2::ContentFile {
15802                path: path.to_string(),
15803                blob_hash: sha256.clone(),
15804                bytes: raw.len() as u64,
15805            }],
15806            None,
15807            &mut || {
15808                nonce += 1;
15809                format!("{nonce:032x}")
15810            },
15811        )
15812        .unwrap();
15813        let root = tree.root.clone().unwrap();
15814        let mut directory_root = root.clone();
15815        let mut proof = Vec::new();
15816        for component in path.split('/') {
15817            let inclusion =
15818                crate::linkmd_v2::create_proof(&directory_root, component, &tree.nodes).unwrap();
15819            let child = match &inclusion {
15820                crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry.child_hash.clone(),
15821                crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
15822                    panic!("fixture path must have an inclusion proof")
15823                }
15824            };
15825            proof.push(json!({
15826                "directory_root": directory_root,
15827                "component": component,
15828                "proof": inclusion,
15829            }));
15830            directory_root = child;
15831        }
15832        let commit_hash = "c".repeat(64);
15833        let pointer = V2PointerBody {
15834            v: 2,
15835            brain: TEST_BRAIN_ID.to_string(),
15836            seq: 1,
15837            commit_hash: commit_hash.clone(),
15838            feed_hash: "f".repeat(64),
15839            content_root: Some(root.clone()),
15840            asset_root: None,
15841            materializer: "dbmd-projection-v1".to_string(),
15842            signer_epoch: 1,
15843            control_revision: "d".repeat(64),
15844            backup_preparation: "e".repeat(64),
15845            prior_pointer_hash: None,
15846            signed_at: "2026-08-21T12:00:00.000Z".to_string(),
15847        };
15848        let manifest = json!({
15849            "v": 2,
15850            "commit": commit_hash,
15851            "content_root": root,
15852            "files": [{
15853                "path": path,
15854                "sha256": sha256,
15855                "bytes": raw.len(),
15856                "proof": proof,
15857            }],
15858            "next_cursor": Value::Null,
15859        })
15860        .to_string();
15861
15862        let path_manifest = manifest.clone();
15863        let (hub, server) = routed_json_hub(1, move |request| {
15864            assert_eq!(
15865                request,
15866                format!(
15867                    "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Ftruth.md",
15868                    "c".repeat(64)
15869                )
15870            );
15871            (200, path_manifest.clone())
15872        });
15873        let state = tempfile::tempdir().unwrap();
15874        let cfg = test_hub_config(hub, state.path().to_path_buf());
15875        let by_path = v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, path)
15876            .unwrap()
15877            .unwrap();
15878        assert_eq!(by_path.sha256, content_sha256(raw.as_bytes()));
15879        assert!(by_path.proof.is_some());
15880        server.join().unwrap();
15881
15882        let id_manifest = manifest;
15883        let (hub, server) = routed_json_hub(1, move |request| {
15884            assert_eq!(
15885                request,
15886                format!(
15887                    "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&id={record_id}",
15888                    "c".repeat(64)
15889                )
15890            );
15891            (200, id_manifest.clone())
15892        });
15893        let state = tempfile::tempdir().unwrap();
15894        let cfg = test_hub_config(hub, state.path().to_path_buf());
15895        let (located_path, by_id) =
15896            v2_manifest_file_by_id(&cfg, TEST_BRAIN_ID, &pointer, record_id).unwrap();
15897        assert_eq!(located_path, path);
15898        assert_eq!(by_id.sha256, content_sha256(raw.as_bytes()));
15899        server.join().unwrap();
15900    }
15901
15902    #[test]
15903    fn canonical_store_pack_matches_the_cross_language_zip32_golden() {
15904        let unsorted = vec![
15905            ("records/a.md".to_string(), "alpha\n".to_string()),
15906            ("DB.md".to_string(), "# db\n".to_string()),
15907        ];
15908        let sorted = vec![
15909            ("DB.md".to_string(), "# db\n".to_string()),
15910            ("records/a.md".to_string(), "alpha\n".to_string()),
15911        ];
15912        let pack = build_store_pack(&unsorted).unwrap();
15913
15914        // This digest is shared with the hub implementation. It locks every
15915        // byte of the wire profile: raw UTF-8 order, STORED payloads, fixed DOS
15916        // epoch, explicit CRC/sizes, Unix regular-0600 attributes, and the
15917        // absence of descriptors/extras/comments/ZIP64.
15918        assert_eq!(pack.len(), 219);
15919        assert_eq!(
15920            content_sha256(&pack),
15921            "972fb2045becaa21588baaf4b349e62a430687fa2c21167b53f4ca0efa6c9408"
15922        );
15923        assert_eq!(pack, build_store_pack(&sorted).unwrap());
15924        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x07\x08"));
15925        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x06"));
15926        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x07"));
15927
15928        assert_eq!(
15929            parse_store_pack(pack).unwrap(),
15930            vec![
15931                ("DB.md".to_string(), b"# db\n".to_vec()),
15932                ("records/a.md".to_string(), b"alpha\n".to_vec()),
15933            ]
15934        );
15935    }
15936
15937    #[test]
15938    fn canonical_store_pack_validates_every_path_before_writing() {
15939        let duplicate = vec![
15940            ("DB.md".to_string(), "first".to_string()),
15941            ("DB.md".to_string(), "second".to_string()),
15942        ];
15943        assert!(build_store_pack(&duplicate)
15944            .unwrap_err()
15945            .to_string()
15946            .contains("duplicate path"));
15947        assert!(matches!(
15948            build_store_pack(&[("../escape.md".to_string(), "no".to_string())]),
15949            Err(LinkError::UnsafePath { .. })
15950        ));
15951    }
15952
15953    #[test]
15954    fn zip64_preflight_rejects_an_entry_count_bomb_before_zip_parsing() {
15955        const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
15956        // One byte stands in for the central directory. The trailer itself is
15957        // structurally valid; the count is the sole reason for refusal.
15958        let mut bytes = vec![0_u8];
15959        let zip64_offset = bytes.len() as u64;
15960        bytes.extend_from_slice(b"PK\x06\x06");
15961        bytes.extend_from_slice(&44_u64.to_le_bytes());
15962        bytes.extend_from_slice(&[0_u8; 12]);
15963        bytes.extend_from_slice(&COUNT.to_le_bytes());
15964        bytes.extend_from_slice(&COUNT.to_le_bytes());
15965        bytes.extend_from_slice(&1_u64.to_le_bytes());
15966        bytes.extend_from_slice(&0_u64.to_le_bytes());
15967        bytes.extend_from_slice(b"PK\x06\x07");
15968        bytes.extend_from_slice(&0_u32.to_le_bytes());
15969        bytes.extend_from_slice(&zip64_offset.to_le_bytes());
15970        bytes.extend_from_slice(&1_u32.to_le_bytes());
15971        bytes.extend_from_slice(b"PK\x05\x06");
15972        bytes.extend_from_slice(&0_u16.to_le_bytes());
15973        bytes.extend_from_slice(&0_u16.to_le_bytes());
15974        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
15975        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
15976        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
15977        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
15978        bytes.extend_from_slice(&0_u16.to_le_bytes());
15979
15980        let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
15981            .unwrap_err()
15982            .to_string();
15983        assert!(error.contains("invalid file count"), "{error}");
15984    }
15985
15986    #[test]
15987    fn zip_preflight_rejects_a_fake_trailing_eocd_before_zip_parsing() {
15988        const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
15989        let mut bytes = vec![0_u8];
15990        let zip64_offset = bytes.len() as u64;
15991        bytes.extend_from_slice(b"PK\x06\x06");
15992        bytes.extend_from_slice(&44_u64.to_le_bytes());
15993        bytes.extend_from_slice(&[0_u8; 12]);
15994        bytes.extend_from_slice(&COUNT.to_le_bytes());
15995        bytes.extend_from_slice(&COUNT.to_le_bytes());
15996        bytes.extend_from_slice(&1_u64.to_le_bytes());
15997        bytes.extend_from_slice(&0_u64.to_le_bytes());
15998        bytes.extend_from_slice(b"PK\x06\x07");
15999        bytes.extend_from_slice(&0_u32.to_le_bytes());
16000        bytes.extend_from_slice(&zip64_offset.to_le_bytes());
16001        bytes.extend_from_slice(&1_u32.to_le_bytes());
16002        bytes.extend_from_slice(b"PK\x05\x06");
16003        bytes.extend_from_slice(&0_u16.to_le_bytes());
16004        bytes.extend_from_slice(&0_u16.to_le_bytes());
16005        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16006        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16007        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16008        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16009        bytes.extend_from_slice(&0_u16.to_le_bytes());
16010        // The old parser trusted this last low-count signature, while
16011        // ZipArchive fell back to the real Zip64 directory and allocated for
16012        // COUNT entries. Its central-directory offsets are deliberately fake.
16013        let fake_eocd = bytes.len() as u32;
16014        bytes.extend_from_slice(b"PK\x05\x06");
16015        bytes.extend_from_slice(&0_u16.to_le_bytes());
16016        bytes.extend_from_slice(&0_u16.to_le_bytes());
16017        bytes.extend_from_slice(&1_u16.to_le_bytes());
16018        bytes.extend_from_slice(&1_u16.to_le_bytes());
16019        bytes.extend_from_slice(&0_u32.to_le_bytes());
16020        bytes.extend_from_slice(&fake_eocd.to_le_bytes());
16021        bytes.extend_from_slice(&0_u16.to_le_bytes());
16022
16023        let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
16024            .unwrap_err()
16025            .to_string();
16026        assert!(error.contains("central directory"), "{error}");
16027    }
16028
16029    #[test]
16030    fn strict_http_status_handling_rejects_redirects_without_panicking() {
16031        let error = ensure_ok(
16032            HubResponse {
16033                status: 302,
16034                body: Some(json!({"redirect": "/elsewhere"})),
16035            },
16036            "mutation",
16037        )
16038        .unwrap_err();
16039        assert!(matches!(error, LinkError::Http { status: 302, .. }));
16040
16041        let error = ensure_raw_ok(
16042            RawHubResponse {
16043                status: 302,
16044                body: br#"{"redirect":"/elsewhere"}"#.to_vec(),
16045            },
16046            "feed",
16047        )
16048        .unwrap_err();
16049        assert!(matches!(error, LinkError::Http { status: 302, .. }));
16050    }
16051
16052    #[cfg(unix)]
16053    #[test]
16054    fn collect_push_files_refuses_external_symlink_and_nested_store() {
16055        use std::os::unix::fs::symlink;
16056
16057        let root = tempfile::tempdir().unwrap();
16058        std::fs::write(
16059            root.path().join("DB.md"),
16060            "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
16061        )
16062        .unwrap();
16063        std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
16064
16065        let external = tempfile::tempdir().unwrap();
16066        let secret = external.path().join("secret.md");
16067        std::fs::write(&secret, "TOP SECRET").unwrap();
16068        symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
16069
16070        let store = Store::open_strict(root.path()).unwrap();
16071        let err = collect_push_files(&store).unwrap_err().to_string();
16072        assert!(err.contains("cannot push"), "{err}");
16073        assert!(
16074            !err.contains("TOP SECRET"),
16075            "external bytes must never leak"
16076        );
16077
16078        std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
16079        let nested = root.path().join("records/nested");
16080        std::fs::create_dir_all(&nested).unwrap();
16081        std::fs::write(
16082            nested.join("DB.md"),
16083            "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
16084        )
16085        .unwrap();
16086        let err = collect_push_files(&store).unwrap_err().to_string();
16087        assert!(err.contains("nested db.md store"), "{err}");
16088    }
16089
16090    #[cfg(unix)]
16091    #[test]
16092    fn remote_push_uses_opened_root_after_path_replacement() {
16093        use std::os::unix::fs::symlink;
16094
16095        let sandbox = tempfile::tempdir().unwrap();
16096        let root = sandbox.path().join("store");
16097        std::fs::create_dir_all(root.join("records/notes")).unwrap();
16098        std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
16099        std::fs::write(
16100            root.join("records/notes/owned.md"),
16101            "---\ntype: note\nsummary: owned\n---\nowned upload\n",
16102        )
16103        .unwrap();
16104        let store = Store::open_strict(&root).unwrap();
16105        let detached = sandbox.path().join("detached");
16106        std::fs::rename(&root, &detached).unwrap();
16107
16108        let replacement = sandbox.path().join("replacement");
16109        std::fs::create_dir_all(replacement.join("records/notes")).unwrap();
16110        std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
16111        std::fs::write(
16112            replacement.join("records/notes/secret.md"),
16113            "---\ntype: note\nsummary: secret\n---\nreplacement sentinel\n",
16114        )
16115        .unwrap();
16116        symlink(&replacement, &root).unwrap();
16117
16118        let files = collect_push_files(&store).unwrap();
16119        let wire_text = files
16120            .iter()
16121            .map(|(path, content)| format!("{path}\n{content}"))
16122            .collect::<Vec<_>>()
16123            .join("\n");
16124        assert!(wire_text.contains("owned upload"));
16125        assert!(!wire_text.contains("replacement sentinel"));
16126        assert!(!wire_text.contains("records/notes/secret.md"));
16127
16128        let remote = signed_remote_fixture();
16129        let (hub, server) = scripted_json_hub(vec![
16130            (200, remote.card),
16131            (200, remote.feed),
16132            (200, json!({"ok": true}).to_string()),
16133        ]);
16134        let state = tempfile::tempdir().unwrap();
16135        let cfg = test_hub_config(hub, state.path().to_path_buf());
16136        let pushed = sync_push(&cfg, TEST_BRAIN_ID, &files).unwrap();
16137        assert_eq!(pushed, json!({"ok": true}));
16138        server.join().unwrap();
16139    }
16140
16141    #[test]
16142    fn signed_feed_item_verifies_identity_hash_and_signature() {
16143        use ring::rand::SystemRandom;
16144        use ring::signature::{Ed25519KeyPair, KeyPair};
16145
16146        const PREFIX: &[u8] = &[
16147            0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
16148        ];
16149        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
16150        let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16151        let mut spki = PREFIX.to_vec();
16152        spki.extend_from_slice(pair.public_key().as_ref());
16153        let public_key = URL_SAFE_NO_PAD.encode(&spki);
16154        let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
16155        let mut entry = FeedEntry {
16156            v: 1,
16157            seq: 1,
16158            ts: "2026-07-14T00:00:00.000Z".to_string(),
16159            brain: format!("ed25519:{fingerprint}"),
16160            public_key: public_key.clone(),
16161            kind: "push".to_string(),
16162            op: "snapshot".to_string(),
16163            pack_sha256: "a".repeat(64),
16164            files: vec![FeedFile {
16165                path: "DB.md".to_string(),
16166                sha256: "b".repeat(64),
16167                bytes: 3,
16168            }],
16169            removed: vec![],
16170            prev_entry_hash: None,
16171            sig: String::new(),
16172        };
16173        let unsigned = UnsignedFeedEntry {
16174            v: entry.v,
16175            seq: entry.seq,
16176            ts: &entry.ts,
16177            brain: &entry.brain,
16178            public_key: &entry.public_key,
16179            kind: &entry.kind,
16180            op: &entry.op,
16181            pack_sha256: &entry.pack_sha256,
16182            files: &entry.files,
16183            removed: &entry.removed,
16184            prev_entry_hash: &entry.prev_entry_hash,
16185        };
16186        entry.sig =
16187            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
16188        let mut exact = serde_json::to_vec(&entry).unwrap();
16189        exact.push(b'\n');
16190        let item = FeedItem {
16191            hash: format!("{:x}", Sha256::digest(&exact)),
16192            entry,
16193        };
16194        let identity = FeedIdentity {
16195            fingerprint,
16196            public_key_spki: public_key,
16197            previous: Vec::new(),
16198            rotations: Vec::new(),
16199        };
16200        assert!(verify_feed_item(&item, &identity).is_ok());
16201        let mut tampered = item;
16202        tampered.entry.pack_sha256 = "c".repeat(64);
16203        assert!(verify_feed_item(&tampered, &identity).is_err());
16204    }
16205
16206    #[test]
16207    fn v2_commit_requires_exact_fields_and_a_valid_genesis_bridge() {
16208        let rng = ring::rand::SystemRandom::new();
16209        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16210        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16211        let (spki, multikey) = public_identity_for(&pair);
16212        let identity = V2HeadIdentity {
16213            custody: "self".to_string(),
16214            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
16215            public_key_spki: spki.clone(),
16216            previous: Vec::new(),
16217            rotations: Vec::new(),
16218        };
16219        let unsigned = json!({
16220            "actor_ref": "a".repeat(64),
16221            "asset_root": Value::Null,
16222            "brain": multikey,
16223            "changes_sha256": "b".repeat(64),
16224            "control_revision": "c".repeat(64),
16225            "materializer": "dbmd-projection-v1",
16226            "op": "changeset",
16227            "parent_asset_root": Value::Null,
16228            "parent_commit": Value::Null,
16229            "parent_root": Value::Null,
16230            "prev_entry_hash": Value::Null,
16231            "public_key": spki,
16232            "seq": 1,
16233            "signer_epoch": 1,
16234            "state_root": "d".repeat(64),
16235            "ts": "2026-08-19T12:00:00.000Z",
16236            "v": 2,
16237            "v1_bridge": {
16238                "feed_hash": "e".repeat(64),
16239                "head_seq": 7,
16240                "pack_sha256": "f".repeat(64),
16241            },
16242        });
16243        let sign_value = |value: Value| {
16244            let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
16245            let mut object = value.as_object().unwrap().clone();
16246            object.insert(
16247                "sig".to_string(),
16248                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16249            );
16250            crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
16251        };
16252        assert!(verified_v2_commit_object(&sign_value(unsigned.clone()), &identity).is_ok());
16253
16254        let mut extra = unsigned.clone();
16255        extra
16256            .as_object_mut()
16257            .unwrap()
16258            .insert("future".to_string(), Value::Bool(true));
16259        assert!(verified_v2_commit_object(&sign_value(extra), &identity).is_err());
16260
16261        let mut missing = unsigned.clone();
16262        missing.as_object_mut().unwrap().remove("v1_bridge");
16263        assert!(verified_v2_commit_object(&sign_value(missing), &identity).is_err());
16264
16265        let mut invalid_bridge = unsigned;
16266        invalid_bridge.as_object_mut().unwrap().insert(
16267            "v1_bridge".to_string(),
16268            json!({"feed_hash": "e".repeat(64), "head_seq": 0, "pack_sha256": "f".repeat(64)}),
16269        );
16270        assert!(verified_v2_commit_object(&sign_value(invalid_bridge), &identity).is_err());
16271    }
16272
16273    #[test]
16274    fn shared_v2_commit_bridge_vector_matches_the_typescript_signer() {
16275        let vector: Value = serde_json::from_str(include_str!(
16276            "../tests/vectors/linkmd-v2-commit-bridge.json"
16277        ))
16278        .unwrap();
16279        let identity_value = vector.get("identity").unwrap();
16280        let identity = V2HeadIdentity {
16281            custody: "self".to_string(),
16282            fingerprint: identity_value
16283                .get("fingerprint")
16284                .and_then(Value::as_str)
16285                .unwrap()
16286                .to_string(),
16287            public_key_spki: identity_value
16288                .get("public_key_spki")
16289                .and_then(Value::as_str)
16290                .unwrap()
16291                .to_string(),
16292            previous: Vec::new(),
16293            rotations: Vec::new(),
16294        };
16295        let private = URL_SAFE_NO_PAD
16296            .decode(
16297                identity_value
16298                    .get("private_key_pkcs8")
16299                    .and_then(Value::as_str)
16300                    .unwrap(),
16301            )
16302            .unwrap();
16303        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&private)
16304            .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(&private))
16305            .unwrap();
16306        let base = vector.get("body").unwrap().as_object().unwrap();
16307
16308        for item in vector.get("valid").unwrap().as_array().unwrap() {
16309            let mut body = base.clone();
16310            body.insert(
16311                "v1_bridge".to_string(),
16312                item.get("v1_bridge").unwrap().clone(),
16313            );
16314            body.insert(
16315                "sig".to_string(),
16316                item.get("signature_base64url").unwrap().clone(),
16317            );
16318            let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
16319            assert!(verified_v2_commit_object(&signed, &identity).is_ok());
16320            assert_eq!(
16321                crate::linkmd_v2::domain_hash_bytes("v2/commit", &signed).unwrap(),
16322                item.get("commit_hash").and_then(Value::as_str).unwrap()
16323            );
16324            assert_eq!(
16325                format!("{:x}", Sha256::digest(&signed)),
16326                item.get("feed_hash").and_then(Value::as_str).unwrap()
16327            );
16328        }
16329
16330        for item in vector.get("invalid").unwrap().as_array().unwrap() {
16331            let mut body = base.clone();
16332            if let Some(remove) = item.get("remove").and_then(Value::as_array) {
16333                for field in remove {
16334                    body.remove(field.as_str().unwrap());
16335                }
16336            }
16337            if let Some(set) = item.get("set").and_then(Value::as_object) {
16338                for (field, value) in set {
16339                    body.insert(field.clone(), value.clone());
16340                }
16341            }
16342            let message = crate::linkmd_v2::canonical_bytes(&Value::Object(body.clone())).unwrap();
16343            body.insert(
16344                "sig".to_string(),
16345                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16346            );
16347            let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
16348            assert!(
16349                verified_v2_commit_object(&signed, &identity).is_err(),
16350                "accepted invalid shared vector {}",
16351                item.get("reason").and_then(Value::as_str).unwrap()
16352            );
16353        }
16354    }
16355
16356    #[test]
16357    fn shared_v2_kept_home_changeset_vector_matches_the_typescript_hub() {
16358        let vector: Value = serde_json::from_str(include_str!(
16359            "../tests/vectors/linkmd-v2-changeset-withheld.json"
16360        ))
16361        .unwrap();
16362        assert_eq!(
16363            vector.get("profile").and_then(Value::as_str),
16364            Some("link.md-v2-changeset-withheld")
16365        );
16366        let canonical =
16367            crate::linkmd_v2::canonical_bytes(vector.get("changeset").unwrap()).unwrap();
16368        let expected = STANDARD
16369            .decode(
16370                vector
16371                    .get("canonical_base64")
16372                    .and_then(Value::as_str)
16373                    .unwrap(),
16374            )
16375            .unwrap();
16376        assert_eq!(canonical, expected);
16377        assert_eq!(
16378            crate::linkmd_v2::domain_hash_bytes("v2/changeset", &canonical).unwrap(),
16379            vector.get("domain_hash").and_then(Value::as_str).unwrap()
16380        );
16381    }
16382
16383    #[test]
16384    fn v2_profile_transition_reverifies_the_exact_signed_v1_bridge() {
16385        let remote = signed_remote_fixture();
16386        let legacy: FeedResponse = serde_json::from_str(&remote.feed).unwrap();
16387        let legacy_item = legacy.entries.first().unwrap();
16388        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&remote.key.pkcs8).unwrap();
16389        let body = json!({
16390            "actor_ref": "a".repeat(64),
16391            "asset_root": Value::Null,
16392            "brain": remote.key.multikey,
16393            "changes_sha256": "b".repeat(64),
16394            "control_revision": "c".repeat(64),
16395            "materializer": "dbmd-projection-v1",
16396            "op": "changeset",
16397            "parent_asset_root": Value::Null,
16398            "parent_commit": Value::Null,
16399            "parent_root": Value::Null,
16400            "prev_entry_hash": Value::Null,
16401            "public_key": remote.key.public_key_spki,
16402            "seq": 1,
16403            "signer_epoch": 1,
16404            "state_root": "d".repeat(64),
16405            "ts": "2026-08-19T12:00:00.000Z",
16406            "v": 2,
16407            "v1_bridge": {
16408                "feed_hash": legacy_item.hash,
16409                "head_seq": legacy_item.entry.seq,
16410                "pack_sha256": legacy_item.entry.pack_sha256,
16411            },
16412        });
16413        let message = crate::linkmd_v2::canonical_bytes(&body).unwrap();
16414        let mut signed = body.as_object().unwrap().clone();
16415        signed.insert(
16416            "sig".to_string(),
16417            Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16418        );
16419        let raw = crate::linkmd_v2::canonical_bytes(&Value::Object(signed)).unwrap();
16420        let commit_hash = crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw).unwrap();
16421        let feed_hash = content_sha256(&raw);
16422        let pointer = V2PointerBody {
16423            v: 2,
16424            brain: TEST_BRAIN_ID.to_string(),
16425            seq: 1,
16426            commit_hash: commit_hash.clone(),
16427            feed_hash: feed_hash.clone(),
16428            content_root: Some("d".repeat(64)),
16429            asset_root: None,
16430            materializer: "dbmd-projection-v1".to_string(),
16431            signer_epoch: 1,
16432            control_revision: "c".repeat(64),
16433            backup_preparation: "e".repeat(64),
16434            prior_pointer_hash: None,
16435            signed_at: "2026-08-19T12:00:00.000Z".to_string(),
16436        };
16437        let v2_page = json!({
16438            "v": 2,
16439            "head_seq": 1,
16440            "head_commit_hash": commit_hash,
16441            "head_feed_hash": feed_hash,
16442            "entries": [{
16443                "seq": 1,
16444                "commit_hash": pointer.commit_hash,
16445                "feed_hash": pointer.feed_hash,
16446                "bytes_base64": STANDARD.encode(&raw),
16447            }],
16448            "next_after": 1,
16449            "complete": true,
16450        })
16451        .to_string();
16452        let identity = V2HeadIdentity {
16453            custody: "self".to_string(),
16454            fingerprint: remote.identity.fingerprint.clone(),
16455            public_key_spki: remote.identity.public_key_spki.clone(),
16456            previous: Vec::new(),
16457            rotations: Vec::new(),
16458        };
16459        let checkpoint = TrustState {
16460            v: 2,
16461            origin: "unused".to_string(),
16462            requested: TEST_BRAIN_ID.to_string(),
16463            brain: TEST_BRAIN_ID.to_string(),
16464            home: None,
16465            anchor: remote.key.multikey.clone(),
16466            current: remote.key.multikey,
16467            head_seq: legacy_item.entry.seq,
16468            feed_hash: Some(legacy_item.hash.clone()),
16469            rotations: Vec::new(),
16470            hub_signer: None,
16471            protocol_profile: None,
16472        };
16473        let (hub, server) = scripted_json_hub(vec![(200, v2_page), (200, remote.feed)]);
16474        let state = tempfile::tempdir().unwrap();
16475        let cfg = test_hub_config(hub, state.path().to_path_buf());
16476        verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &checkpoint).unwrap();
16477        server.join().unwrap();
16478
16479        let mut wrong = checkpoint;
16480        wrong.feed_hash = Some("0".repeat(64));
16481        let (hub, server) = scripted_json_hub(vec![(
16482            200,
16483            json!({
16484                "v": 2,
16485                "head_seq": 1,
16486                "head_commit_hash": pointer.commit_hash,
16487                "head_feed_hash": pointer.feed_hash,
16488                "entries": [{
16489                    "seq": 1,
16490                    "commit_hash": pointer.commit_hash,
16491                    "feed_hash": pointer.feed_hash,
16492                    "bytes_base64": STANDARD.encode(&raw),
16493                }],
16494                "next_after": 1,
16495                "complete": true,
16496            })
16497            .to_string(),
16498        )]);
16499        let state = tempfile::tempdir().unwrap();
16500        let cfg = test_hub_config(hub, state.path().to_path_buf());
16501        assert!(verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &wrong,).is_err());
16502        server.join().unwrap();
16503    }
16504
16505    #[test]
16506    fn v2_commit_signer_epoch_follows_the_authenticated_rotation_boundary() {
16507        let rng = ring::rand::SystemRandom::new();
16508        let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16509        let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
16510        let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16511        let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
16512        let (old_spki, old_multikey) = public_identity_for(&old);
16513        let (new_spki, new_multikey) = public_identity_for(&new);
16514        let rotation_unsigned = serde_json::to_string(&UnsignedRotation {
16515            v: 1,
16516            op: "rotate",
16517            brain: &old_multikey,
16518            public_key: &old_spki,
16519            new_brain: &new_multikey,
16520            new_public_key: &new_spki,
16521            prior_head_seq: 1,
16522            prior_feed_hash: Some(&"9".repeat(64)),
16523            ts: "2026-08-19T12:01:00.000Z".to_string(),
16524        })
16525        .unwrap();
16526        let rotation_sig = URL_SAFE_NO_PAD.encode(old.sign(rotation_unsigned.as_bytes()).as_ref());
16527        let rotation = format!(
16528            "{},\"sig\":\"{}\"}}",
16529            &rotation_unsigned[..rotation_unsigned.len() - 1],
16530            rotation_sig
16531        );
16532        let identity = V2HeadIdentity {
16533            custody: "self".to_string(),
16534            fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
16535            public_key_spki: new_spki.clone(),
16536            previous: vec![V2PreviousIdentity {
16537                fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
16538                public_key_spki: old_spki.clone(),
16539            }],
16540            rotations: vec![rotation],
16541        };
16542        let commit = |seq: u64,
16543                      epoch: u64,
16544                      multikey: &str,
16545                      spki: &str,
16546                      pair: &ring::signature::Ed25519KeyPair| {
16547            let value = json!({
16548                "actor_ref": "a".repeat(64),
16549                "asset_root": Value::Null,
16550                "brain": multikey,
16551                "changes_sha256": "b".repeat(64),
16552                "control_revision": "c".repeat(64),
16553                "materializer": "dbmd-projection-v1",
16554                "op": "changeset",
16555                "parent_asset_root": Value::Null,
16556                "parent_commit": if seq == 1 { Value::Null } else { Value::String("d".repeat(64)) },
16557                "parent_root": if seq == 1 { Value::Null } else { Value::String("e".repeat(64)) },
16558                "prev_entry_hash": if seq == 1 { Value::Null } else { Value::String("f".repeat(64)) },
16559                "public_key": spki,
16560                "seq": seq,
16561                "signer_epoch": epoch,
16562                "state_root": "1".repeat(64),
16563                "ts": "2026-08-19T12:00:00.000Z",
16564                "v": 2,
16565                "v1_bridge": Value::Null,
16566            });
16567            let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
16568            let mut object = value.as_object().unwrap().clone();
16569            object.insert(
16570                "sig".to_string(),
16571                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16572            );
16573            crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
16574        };
16575
16576        assert!(verified_v2_commit_object(
16577            &commit(1, 1, &old_multikey, &old_spki, &old),
16578            &identity,
16579        )
16580        .is_ok());
16581        assert!(verified_v2_commit_object(
16582            &commit(2, 2, &new_multikey, &new_spki, &new),
16583            &identity,
16584        )
16585        .is_ok());
16586        assert!(verified_v2_commit_object(
16587            &commit(2, 1, &old_multikey, &old_spki, &old),
16588            &identity,
16589        )
16590        .is_err());
16591        assert!(verified_v2_commit_object(
16592            &commit(1, 2, &new_multikey, &new_spki, &new),
16593            &identity,
16594        )
16595        .is_err());
16596    }
16597
16598    #[test]
16599    fn a_self_custody_entry_verifies_like_any_hub_entry() {
16600        let rng = ring::rand::SystemRandom::new();
16601        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16602        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16603        let (spki, multikey) = public_identity_for(&pair);
16604        let key = AgentSigningKey {
16605            pkcs8: pkcs8.as_ref().to_vec(),
16606            multikey: multikey.clone(),
16607            public_key_spki: spki.clone(),
16608        };
16609        let files = vec![WireFeedFile {
16610            path: "DB.md".to_string(),
16611            sha256: "a".repeat(64),
16612            bytes: 3,
16613        }];
16614        let raw = self_custody_entry(
16615            &key,
16616            1,
16617            "2026-07-23T12:00:00.000Z".to_string(),
16618            &"c".repeat(64),
16619            &files,
16620            None,
16621        )
16622        .unwrap();
16623        // The exact client serialization parses as a feed entry and passes the
16624        // SAME verifier every subscribe read runs — the self-custody path
16625        // produces first-class wire-profile-v1 entries.
16626        let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
16627        let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
16628        let item = FeedItem { hash, entry };
16629        let identity = FeedIdentity {
16630            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
16631            public_key_spki: spki,
16632            previous: Vec::new(),
16633            rotations: Vec::new(),
16634        };
16635        assert!(verify_feed_item(&item, &identity).is_ok());
16636    }
16637
16638    #[test]
16639    fn identity_rotation_requires_old_key_signature_and_preserves_the_pin() {
16640        let rng = ring::rand::SystemRandom::new();
16641        let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16642        let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
16643        let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16644        let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
16645        let (old_spki, old_multikey) = public_identity_for(&old);
16646        let (new_spki, new_multikey) = public_identity_for(&new);
16647        let unsigned = serde_json::to_string(&UnsignedRotation {
16648            v: 1,
16649            op: "rotate",
16650            brain: &old_multikey,
16651            public_key: &old_spki,
16652            new_brain: &new_multikey,
16653            new_public_key: &new_spki,
16654            prior_head_seq: 1,
16655            prior_feed_hash: Some(&"a".repeat(64)),
16656            ts: "2026-07-30T12:00:00.000Z".to_string(),
16657        })
16658        .unwrap();
16659        let signature = URL_SAFE_NO_PAD.encode(old.sign(unsigned.as_bytes()).as_ref());
16660        let rotation = format!(
16661            "{},\"sig\":\"{}\"}}",
16662            &unsigned[..unsigned.len() - 1],
16663            signature
16664        );
16665        let identity = FeedIdentity {
16666            fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
16667            public_key_spki: new_spki,
16668            previous: vec![PreviousIdentity {
16669                fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
16670                public_key_spki: old_spki,
16671            }],
16672            rotations: vec![rotation],
16673        };
16674        let pin = TrustState {
16675            v: 2,
16676            origin: "https://hub.example".to_string(),
16677            requested: "brain".to_string(),
16678            brain: "brain".to_string(),
16679            home: None,
16680            anchor: old_multikey.clone(),
16681            current: old_multikey.clone(),
16682            head_seq: 1,
16683            feed_hash: Some("a".repeat(64)),
16684            rotations: Vec::new(),
16685            hub_signer: None,
16686            protocol_profile: None,
16687        };
16688        assert_eq!(
16689            verify_identity_chain(&identity, Some(&pin)).unwrap(),
16690            old_multikey
16691        );
16692        let mut accepted = pin.clone();
16693        accepted.current = new_multikey.clone();
16694        accepted.rotations = identity.rotations.clone();
16695        let alternate_unsigned = serde_json::to_string(&UnsignedRotation {
16696            v: 1,
16697            op: "rotate",
16698            brain: &old_multikey,
16699            public_key: &identity.previous[0].public_key_spki,
16700            new_brain: &new_multikey,
16701            new_public_key: &identity.public_key_spki,
16702            prior_head_seq: 1,
16703            prior_feed_hash: Some(&"a".repeat(64)),
16704            ts: "2026-07-30T12:00:01.000Z".to_string(),
16705        })
16706        .unwrap();
16707        let alternate_signature =
16708            URL_SAFE_NO_PAD.encode(old.sign(alternate_unsigned.as_bytes()).as_ref());
16709        let mut rewritten = identity.clone();
16710        rewritten.rotations[0] = format!(
16711            "{},\"sig\":\"{}\"}}",
16712            &alternate_unsigned[..alternate_unsigned.len() - 1],
16713            alternate_signature
16714        );
16715        assert!(
16716            verify_identity_chain(&rewritten, Some(&accepted)).is_err(),
16717            "an alternate valid statement must not rewrite accepted history"
16718        );
16719
16720        let mut stale_entry = FeedEntry {
16721            v: 1,
16722            seq: 2,
16723            ts: "2026-07-30T12:01:00.000Z".to_string(),
16724            brain: pin.current.clone(),
16725            public_key: identity.previous[0].public_key_spki.clone(),
16726            kind: "push".to_string(),
16727            op: "snapshot".to_string(),
16728            pack_sha256: "b".repeat(64),
16729            files: Vec::new(),
16730            removed: Vec::new(),
16731            prev_entry_hash: pin.feed_hash.clone(),
16732            sig: String::new(),
16733        };
16734        let stale_unsigned = UnsignedFeedEntry {
16735            v: stale_entry.v,
16736            seq: stale_entry.seq,
16737            ts: &stale_entry.ts,
16738            brain: &stale_entry.brain,
16739            public_key: &stale_entry.public_key,
16740            kind: &stale_entry.kind,
16741            op: &stale_entry.op,
16742            pack_sha256: &stale_entry.pack_sha256,
16743            files: &stale_entry.files,
16744            removed: &stale_entry.removed,
16745            prev_entry_hash: &stale_entry.prev_entry_hash,
16746        };
16747        stale_entry.sig = URL_SAFE_NO_PAD.encode(
16748            old.sign(&serde_json::to_vec(&stale_unsigned).unwrap())
16749                .as_ref(),
16750        );
16751        let mut stale_exact = serde_json::to_vec(&stale_entry).unwrap();
16752        stale_exact.push(b'\n');
16753        let stale_item = FeedItem {
16754            hash: content_sha256(&stale_exact),
16755            entry: stale_entry,
16756        };
16757        assert!(
16758            reject_retired_signer_after_checkpoint(&identity, Some(&accepted), &stale_item)
16759                .is_err(),
16760            "a key retired before the checkpoint must never append after it"
16761        );
16762        assert!(
16763            verify_feed_item(&stale_item, &identity).is_err(),
16764            "an old key must never append after its signed rotation boundary"
16765        );
16766
16767        let mut missing = identity.clone();
16768        missing.rotations.clear();
16769        assert!(verify_identity_chain(&missing, Some(&pin)).is_err());
16770
16771        let mut tampered = identity;
16772        tampered.rotations[0] = tampered.rotations[0].replace("\"sig\":\"", "\"sig\":\"A");
16773        assert!(verify_identity_chain(&tampered, Some(&pin)).is_err());
16774    }
16775
16776    #[cfg(unix)]
16777    #[test]
16778    fn key_creation_refuses_a_planted_symlink_without_touching_its_target() {
16779        use std::os::unix::fs::symlink;
16780
16781        let dir = tempfile::tempdir().unwrap();
16782        let target = dir.path().join("valuable.txt");
16783        let planted = dir.path().join("agent.key");
16784        std::fs::write(&target, "do not overwrite").unwrap();
16785        symlink(&target, &planted).unwrap();
16786
16787        assert!(matches!(
16788            generate_agent_key(&planted),
16789            Err(LinkError::BadAgentKey { .. })
16790        ));
16791        assert_eq!(std::fs::read_to_string(target).unwrap(), "do not overwrite");
16792    }
16793
16794    #[cfg(unix)]
16795    #[test]
16796    fn key_creation_refuses_a_symlinked_parent_without_writing_through_it() {
16797        use std::os::unix::fs::symlink;
16798
16799        let root = tempfile::tempdir().unwrap();
16800        let outside = tempfile::tempdir().unwrap();
16801        symlink(outside.path(), root.path().join("redirect")).unwrap();
16802
16803        assert!(generate_agent_key(&root.path().join("redirect/agent.key")).is_err());
16804        assert!(!outside.path().join("agent.key").exists());
16805    }
16806
16807    // ── Address parsing ─────────────────────────────────────────────────────
16808
16809    #[test]
16810    fn address_bare_brain_with_and_without_sigil() {
16811        for raw in ["@acme-ops", "acme-ops"] {
16812            let a = Address::parse(raw).expect(raw);
16813            assert_eq!(a.brain, "acme-ops");
16814            assert_eq!(a.target, None);
16815        }
16816    }
16817
16818    #[test]
16819    fn address_ulid_target_parses_as_id() {
16820        let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
16821        assert_eq!(a.brain, "acme");
16822        assert_eq!(
16823            a.target,
16824            Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
16825        );
16826    }
16827
16828    #[test]
16829    fn address_md_path_target_parses_as_path() {
16830        let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
16831        assert_eq!(
16832            a.target,
16833            Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
16834        );
16835    }
16836
16837    #[test]
16838    fn address_rejects_malformed_forms() {
16839        for raw in [
16840            "",
16841            "@",
16842            "@/x",
16843            "@acme/",
16844            "@acme/../etc/passwd",
16845            "@acme/records/.hidden.md",
16846            "@ACME",             // uppercase is not a hub ref shape
16847            "@acme/notes/x.txt", // target is neither ULID nor .md path
16848            "@a b",              // whitespace
16849        ] {
16850            assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
16851        }
16852    }
16853
16854    // ── Path safety ─────────────────────────────────────────────────────────
16855
16856    #[test]
16857    fn safe_paths_accept_store_shapes_and_reject_escapes() {
16858        for ok in [
16859            "DB.md",
16860            "assets.jsonl",
16861            "records/clients/lumio.md",
16862            "sources/emails/2026/07/x.md",
16863        ] {
16864            assert!(safe_store_rel_path(ok), "should accept {ok:?}");
16865        }
16866        for bad in [
16867            "",
16868            "/etc/passwd",
16869            "../up.md",
16870            "records/../../up.md",
16871            "records//x.md",
16872            ".dbmd/config",
16873            "records/.hidden/x.md",
16874            "records/a b.md",
16875            "records\\win.md",
16876        ] {
16877            assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
16878        }
16879    }
16880
16881    #[cfg(unix)]
16882    #[test]
16883    fn opened_destination_capability_survives_an_ancestor_path_swap() {
16884        use std::os::unix::fs::symlink;
16885
16886        let work = tempfile::tempdir().unwrap();
16887        let outside = tempfile::tempdir().unwrap();
16888        let original = work.path().join("destination");
16889        let moved = work.path().join("destination-moved");
16890        let directory = open_or_create_dir_nofollow(&original).unwrap();
16891
16892        std::fs::rename(&original, &moved).unwrap();
16893        symlink(outside.path(), &original).unwrap();
16894        write_pull_entries_beneath_dir(
16895            &directory,
16896            &[("records/note.md".to_string(), b"held inode".to_vec())],
16897        )
16898        .unwrap();
16899
16900        assert_eq!(
16901            std::fs::read(moved.join("records/note.md")).unwrap(),
16902            b"held inode"
16903        );
16904        assert!(!outside.path().join("records/note.md").exists());
16905    }
16906
16907    // ── Config resolution (flag + file precedence; env is covered by the CLI
16908    //    integration tests, where a child process isolates it) ───────────────
16909
16910    #[test]
16911    fn hub_config_flag_beats_file_and_requires_some_source() {
16912        let dir = tempfile::tempdir().unwrap();
16913        std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
16914        std::fs::write(
16915            dir.path().join(CONFIG_REL_PATH),
16916            "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
16917        )
16918        .unwrap();
16919
16920        let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
16921        assert_eq!(from_flag.hub, "https://flag.example.com");
16922
16923        let from_file = hub_config(None, dir.path()).unwrap();
16924        assert_eq!(from_file.hub, "https://file.example.com");
16925
16926        let none = hub_config(None, tempfile::tempdir().unwrap().path());
16927        assert!(matches!(none, Err(LinkError::NoHub)));
16928    }
16929
16930    #[test]
16931    fn https_guard_allows_loopback_only_for_plain_http() {
16932        assert!(assert_safe_hub("https://hub.example.com").is_ok());
16933        assert!(assert_safe_hub("http://localhost:3000").is_ok());
16934        assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
16935        assert!(assert_safe_hub("http://[::1]:3000").is_ok());
16936        assert!(matches!(
16937            assert_safe_hub("http://hub.example.com"),
16938            Err(LinkError::UnsafeHub { .. })
16939        ));
16940        assert!(matches!(
16941            assert_safe_hub("hub.example.com"),
16942            Err(LinkError::UnsafeHub { .. })
16943        ));
16944        assert!(matches!(
16945            assert_safe_hub("http://localhost:80@127.0.0.1:1"),
16946            Err(LinkError::UnsafeHub { .. })
16947        ));
16948        assert!(matches!(
16949            assert_safe_hub("https://hub.example.com@attacker.example"),
16950            Err(LinkError::UnsafeHub { .. })
16951        ));
16952        assert!(matches!(
16953            assert_safe_hub("https://hub.example.com/base"),
16954            Err(LinkError::UnsafeHub { .. })
16955        ));
16956    }
16957
16958    #[test]
16959    fn registry_ssrf_classifier_rejects_local_private_and_documentation_ranges() {
16960        for blocked in [
16961            "127.0.0.1",
16962            "10.0.0.1",
16963            "100.64.0.1",
16964            "169.254.169.254",
16965            "172.16.0.1",
16966            "192.168.0.1",
16967            "192.88.99.1",
16968            "198.18.0.1",
16969            "203.0.113.1",
16970            "::1",
16971            "fe80::1",
16972            "fd00::1",
16973            "2001:db8::1",
16974            "2001:1::1",
16975            "2002:7f00:1::",
16976            "3fff::1",
16977        ] {
16978            assert!(
16979                !is_public_registry_ip(blocked.parse().unwrap()),
16980                "must block {blocked}"
16981            );
16982        }
16983        assert!(is_public_registry_ip("1.1.1.1".parse().unwrap()));
16984        assert!(is_public_registry_ip(
16985            "2606:4700:4700::1111".parse().unwrap()
16986        ));
16987        assert!(is_public_registry_ip("3fff:1000::1".parse().unwrap()));
16988    }
16989
16990    #[test]
16991    fn registry_dns_answer_is_pinned_and_cannot_rebind_or_change_authority() {
16992        use ureq::Resolver as _;
16993
16994        let pinned: std::net::SocketAddr = "1.1.1.1:443".parse().unwrap();
16995        let resolver = PinnedRegistryResolver {
16996            netloc: "home.example:443".to_string(),
16997            addresses: vec![pinned],
16998        };
16999        assert_eq!(resolver.resolve("home.example:443").unwrap(), vec![pinned]);
17000        assert!(resolver.resolve("127.0.0.1:443").is_err());
17001        assert_eq!(
17002            resolver.resolve("home.example:443").unwrap(),
17003            vec![pinned],
17004            "subsequent connects reuse the validated answer instead of DNS"
17005        );
17006    }
17007
17008    #[test]
17009    fn production_object_urls_and_store_selected_hubs_cannot_reach_private_ips() {
17010        let cfg = HubConfig {
17011            hub: "https://hub.example".to_string(),
17012            key: None,
17013            agent_key: None,
17014            brain_key: None,
17015            state_dir: tempfile::tempdir().unwrap().keep(),
17016            store_selected: false,
17017        };
17018        assert!(
17019            presigned_agent(&cfg, "https://127.0.0.1/private").is_err(),
17020            "a production hub must not turn its presigned URL into an SSRF primitive"
17021        );
17022
17023        let store_selected = HubConfig {
17024            hub: "https://127.0.0.1".to_string(),
17025            store_selected: true,
17026            ..cfg
17027        };
17028        assert!(
17029            hub_agent(&store_selected).is_err(),
17030            "bytes in a cloned store must not select a private-network hub"
17031        );
17032    }
17033
17034    #[test]
17035    fn presigned_download_ceiling_cannot_overflow_at_u64_max() {
17036        assert_eq!(
17037            one_past_bounded_limit(MAX_PACK_BYTES),
17038            Some(MAX_PACK_BYTES + 1),
17039            "the presigned reader consumes exactly one refusal byte beyond its fixed pack cap"
17040        );
17041        assert_eq!(
17042            presigned_download_read_limit(),
17043            MAX_PACK_BYTES + 1,
17044            "the presigned reader is capped by the client constant, not a hub response"
17045        );
17046        assert_eq!(
17047            one_past_bounded_limit(u64::MAX),
17048            None,
17049            "the former attacker-controlled u64::MAX + 1 shape must fail without overflow"
17050        );
17051    }
17052
17053    #[test]
17054    fn https_guard_matches_the_scheme_case_insensitively() {
17055        // RFC 3986 schemes are case-insensitive: an uppercase-scheme HTTPS
17056        // hub is still HTTPS, never a misleading non-HTTPS refusal.
17057        assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
17058        assert!(assert_safe_hub("Https://hub.example.com").is_ok());
17059        // And an uppercase plain-HTTP hub is still refused outside loopback.
17060        assert!(matches!(
17061            assert_safe_hub("HTTP://hub.example.com"),
17062            Err(LinkError::UnsafeHub { .. })
17063        ));
17064    }
17065
17066    #[test]
17067    fn clean_key_refuses_paste_artifacts_without_echoing() {
17068        assert_eq!(clean_key("  vc_account_abc  ").unwrap(), "vc_account_abc");
17069        for bad in ["vc account", "vc\naccount", "ключ", ""] {
17070            let err = clean_key(bad).unwrap_err();
17071            assert!(matches!(err, LinkError::BadKey));
17072            assert!(
17073                !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
17074                "error must not echo the key"
17075            );
17076        }
17077    }
17078
17079    // ── Verb entry gates: refs must never reshape the request path ──────────
17080
17081    /// A config whose hub passes the loopback guard but is never listened on:
17082    /// every refusal below must come from the entry gate BEFORE a request
17083    /// exists — a dial on this dead port would surface `Transport` instead.
17084    fn dead_hub() -> HubConfig {
17085        HubConfig {
17086            hub: "http://127.0.0.1:9".to_string(),
17087            key: Some("k".to_string()),
17088            agent_key: None,
17089            brain_key: None,
17090            state_dir: PathBuf::from("."),
17091            store_selected: false,
17092        }
17093    }
17094
17095    #[test]
17096    fn request_retries_a_connection_failure_before_sending() {
17097        use std::io::{Read as _, Write as _};
17098        use std::net::TcpListener;
17099        use std::thread;
17100        use std::time::Duration;
17101
17102        let probe = TcpListener::bind("127.0.0.1:0").unwrap();
17103        let address = probe.local_addr().unwrap();
17104        drop(probe);
17105        let server = thread::spawn(move || {
17106            thread::sleep(Duration::from_millis(40));
17107            let listener = TcpListener::bind(address).unwrap();
17108            let (mut stream, _) = listener.accept().unwrap();
17109            let mut request_bytes = [0_u8; 1024];
17110            let _ = stream.read(&mut request_bytes).unwrap();
17111            stream
17112                .write_all(
17113                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
17114                )
17115                .unwrap();
17116        });
17117        let cfg = HubConfig {
17118            hub: format!("http://{address}"),
17119            key: None,
17120            agent_key: None,
17121            brain_key: None,
17122            state_dir: tempfile::tempdir().unwrap().keep(),
17123            store_selected: false,
17124        };
17125
17126        let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
17127        assert_eq!(response.status, 200);
17128        assert_eq!(response.body, Some(json!({ "ok": true })));
17129        server.join().unwrap();
17130    }
17131
17132    #[test]
17133    fn a_commit_goes_back_for_a_receipt_it_lost() {
17134        use std::io::{Read as _, Write as _};
17135        use std::net::TcpListener;
17136        use std::thread;
17137
17138        // The mutation coordinate is derived from the observed base and the
17139        // exact operation set so a repeat is the SAME effect. A commit that
17140        // lands durably and then loses its connection must therefore be asked
17141        // after, not reported as a failure — which is what happened to a
17142        // 32,338-file store that had already committed.
17143        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17144        let address = listener.local_addr().unwrap();
17145        let server = thread::spawn(move || {
17146            // First attempt: promise a body, hang up mid-way.
17147            let (mut first, _) = listener.accept().unwrap();
17148            let mut bytes = [0_u8; 4096];
17149            let _ = first.read(&mut bytes).unwrap();
17150            first
17151                .write_all(
17152                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 90\r\nConnection: close\r\n\r\n{\"v\":2",
17153                )
17154                .unwrap();
17155            drop(first);
17156            // Second attempt: the receipt for the commit that already landed.
17157            let (mut second, _) = listener.accept().unwrap();
17158            let _ = second.read(&mut bytes).unwrap();
17159            second
17160                .write_all(
17161                    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\"}",
17162                )
17163                .unwrap();
17164        });
17165        let cfg = HubConfig {
17166            hub: format!("http://{address}"),
17167            key: Some("k".to_string()),
17168            agent_key: None,
17169            brain_key: None,
17170            state_dir: tempfile::tempdir().unwrap().keep(),
17171            store_selected: false,
17172        };
17173
17174        let response = request_patient(
17175            &cfg,
17176            "POST",
17177            "/api/hub/brains/b/v2/commits",
17178            Some(&json!({ "mutation_id": "dbmd-1" })),
17179            Auth::Required,
17180        )
17181        .expect("the receipt is collected on the second ask");
17182        assert_eq!(response.status, 200);
17183        assert_eq!(
17184            response
17185                .body
17186                .as_ref()
17187                .and_then(|value| value.get("outcome"))
17188                .and_then(Value::as_str),
17189            Some("converged"),
17190            "an already-applied mutation answers with its receipt"
17191        );
17192        server.join().unwrap();
17193    }
17194
17195    #[test]
17196    fn a_body_that_dies_mid_stream_is_a_transport_failure() {
17197        use std::io::{Read as _, Write as _};
17198        use std::net::TcpListener;
17199        use std::thread;
17200
17201        // A connection that fails while the body is arriving is the same class
17202        // of problem as one that fails before it arrives at all. Typed as a
17203        // bare io error it matched no retry path and named no peer, and one
17204        // stalled body ended a whole migration reporting only "timed out
17205        // reading response".
17206        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17207        let address = listener.local_addr().unwrap();
17208        let server = thread::spawn(move || {
17209            let (mut stream, _) = listener.accept().unwrap();
17210            let mut request_bytes = [0_u8; 1024];
17211            let _ = stream.read(&mut request_bytes).unwrap();
17212            // Promise a hundred bytes, send ten, hang up.
17213            stream
17214                .write_all(
17215                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
17216                )
17217                .unwrap();
17218        });
17219        let cfg = HubConfig {
17220            hub: format!("http://{address}"),
17221            key: None,
17222            agent_key: None,
17223            brain_key: None,
17224            state_dir: tempfile::tempdir().unwrap().keep(),
17225            store_selected: false,
17226        };
17227
17228        let error = request(&cfg, "GET", "/truncated", None, Auth::None)
17229            .expect_err("a truncated body must not read as success");
17230        match error {
17231            LinkError::Transport { hub, .. } => {
17232                assert!(hub.contains(&address.to_string()), "names its peer: {hub}");
17233            }
17234            other => panic!("expected a transport failure, got {other:?}"),
17235        }
17236        server.join().unwrap();
17237    }
17238
17239    #[test]
17240    fn endpoint_cap_refuses_a_body_before_json_parsing() {
17241        let (hub, server) = scripted_json_hub(vec![(200, "x".repeat(2_048))]);
17242        let cfg = HubConfig {
17243            hub,
17244            key: None,
17245            agent_key: None,
17246            brain_key: None,
17247            state_dir: tempfile::tempdir().unwrap().keep(),
17248            store_selected: false,
17249        };
17250
17251        assert!(matches!(
17252            request_capped(&cfg, "GET", "/bounded", None, Auth::None, 1_024),
17253            Err(LinkError::ResponseTooLarge { .. })
17254        ));
17255        server.join().unwrap();
17256    }
17257
17258    #[test]
17259    fn overall_deadline_stops_a_dribbled_response_body() {
17260        use std::io::{Read as _, Write as _};
17261        use std::net::TcpListener;
17262        use std::time::{Duration, Instant};
17263
17264        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17265        let url = format!("http://{}/dribble", listener.local_addr().unwrap());
17266        let server = std::thread::spawn(move || {
17267            let (mut stream, _) = listener.accept().unwrap();
17268            let mut request = [0_u8; 1024];
17269            let _ = stream.read(&mut request);
17270            stream
17271                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nConnection: close\r\n\r\n")
17272                .unwrap();
17273            for byte in [b'x'; 32] {
17274                if stream.write_all(&[byte]).is_err() {
17275                    break;
17276                }
17277                std::thread::sleep(Duration::from_millis(40));
17278            }
17279        });
17280        let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
17281        let started = Instant::now();
17282        let response = http.get(&url).call().unwrap();
17283        let mut body = Vec::new();
17284        let error = response
17285            .into_reader()
17286            .read_to_end(&mut body)
17287            .expect_err("per-read progress must not reset the overall deadline");
17288        assert!(
17289            started.elapsed() < Duration::from_millis(700),
17290            "dribbled body exceeded the wall-clock budget: {error}"
17291        );
17292        server.join().unwrap();
17293    }
17294
17295    #[test]
17296    fn overall_deadline_stops_a_stalled_upload() {
17297        use std::net::TcpListener;
17298        use std::time::{Duration, Instant};
17299
17300        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17301        let url = format!("http://{}/upload", listener.local_addr().unwrap());
17302        let server = std::thread::spawn(move || {
17303            let (_stream, _) = listener.accept().unwrap();
17304            // Never consume the request body. Once the kernel send buffer fills,
17305            // the client must leave on its absolute write deadline.
17306            std::thread::sleep(Duration::from_millis(600));
17307        });
17308        let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
17309        let body = vec![0x5a; 32 * 1024 * 1024];
17310        let started = Instant::now();
17311        let error = http
17312            .put(&url)
17313            .send_bytes(&body)
17314            .expect_err("stalled request-body writes must time out");
17315        assert!(
17316            started.elapsed() < Duration::from_millis(700),
17317            "stalled upload exceeded the wall-clock budget: {error}"
17318        );
17319        server.join().unwrap();
17320    }
17321
17322    #[test]
17323    fn verb_entry_gates_accept_the_hub_ref_shapes() {
17324        for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
17325            assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
17326            assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
17327            assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
17328        }
17329    }
17330
17331    #[test]
17332    fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
17333        let cfg = dead_hub();
17334        for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
17335            assert!(
17336                matches!(
17337                    sync_pull(&cfg, bad, None),
17338                    Err(LinkError::BadAddress { .. })
17339                ),
17340                "sync_pull must refuse {bad:?}"
17341            );
17342            assert!(
17343                matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
17344                "sync_push must refuse {bad:?}"
17345            );
17346            assert!(
17347                matches!(
17348                    grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
17349                    Err(LinkError::BadAddress { .. })
17350                ),
17351                "grant_issue must refuse {bad:?}"
17352            );
17353            assert!(
17354                matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
17355                "grant_list must refuse {bad:?}"
17356            );
17357            assert!(
17358                matches!(
17359                    grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
17360                    Err(LinkError::BadAddress { .. })
17361                ),
17362                "grant_revoke must refuse brain {bad:?}"
17363            );
17364            assert!(
17365                matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
17366                "head must refuse {bad:?}"
17367            );
17368        }
17369    }
17370
17371    #[test]
17372    fn grant_revoke_refuses_url_reshaping_grant_ids() {
17373        let cfg = dead_hub();
17374        for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
17375            assert!(
17376                matches!(
17377                    grant_revoke(&cfg, "acme", bad),
17378                    Err(LinkError::BadGrantId { .. })
17379                ),
17380                "grant_revoke must refuse grant id {bad:?}"
17381            );
17382        }
17383    }
17384
17385    #[test]
17386    fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
17387        let cfg = dead_hub();
17388        for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
17389            assert!(
17390                matches!(
17391                    propose(&cfg, bad, "intake", "hi"),
17392                    Err(LinkError::BadAddress { .. })
17393                ),
17394                "propose must refuse handle {bad:?}"
17395            );
17396        }
17397        let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
17398        assert!(matches!(
17399            propose(&cfg, "acme-site", "intake", &oversize),
17400            Err(LinkError::ProposeTooLarge { .. })
17401        ));
17402        // A clean handle + in-cap body passes both gates: the failure is now
17403        // the (dead) wire, proving the gates refuse shape, not the verb.
17404        assert!(matches!(
17405            propose(&cfg, "acme-site", "intake", "hi"),
17406            Err(LinkError::Transport { .. })
17407        ));
17408    }
17409
17410    #[test]
17411    fn resolve_refuses_a_hand_built_unsafe_address() {
17412        let cfg = dead_hub();
17413        for brain in ["../up", "a/b", "a?x", "a#f"] {
17414            let addr = Address {
17415                brain: brain.to_string(),
17416                target: None,
17417            };
17418            assert!(
17419                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
17420                "resolve must refuse brain {brain:?}"
17421            );
17422        }
17423        for target in [
17424            AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
17425            AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), // not the minted shape
17426            AddressTarget::Path("../up.md".to_string()),
17427            AddressTarget::Path("records/x.md#frag".to_string()),
17428        ] {
17429            let addr = Address {
17430                brain: "acme".to_string(),
17431                target: Some(target.clone()),
17432            };
17433            assert!(
17434                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
17435                "resolve must refuse target {target:?}"
17436            );
17437        }
17438    }
17439
17440    #[test]
17441    fn v2_final_barrier_refuses_to_advance_a_remote_ahead_checkout() {
17442        let mut local = std::collections::BTreeMap::new();
17443        local.insert("records/a.md".to_string(), ("a".repeat(64), 0));
17444        local.insert("records/b.md".to_string(), ("b".repeat(64), 0));
17445        let mut remote = std::collections::BTreeMap::new();
17446        remote.insert(
17447            "records/a.md".to_string(),
17448            V2BaselineFile {
17449                sha256: "c".repeat(64),
17450                bytes: 1,
17451                proof: None,
17452            },
17453        );
17454        remote.insert(
17455            "records/b.md".to_string(),
17456            V2BaselineFile {
17457                sha256: "b".repeat(64),
17458                bytes: 1,
17459                proof: None,
17460            },
17461        );
17462        assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
17463    }
17464
17465    #[test]
17466    fn v2_final_barrier_ignores_only_explicit_kept_home_paths() {
17467        let local = std::collections::BTreeMap::new();
17468        let mut remote = std::collections::BTreeMap::new();
17469        remote.insert(
17470            "private/local.md".to_string(),
17471            V2BaselineFile {
17472                sha256: "d".repeat(64),
17473                bytes: 1,
17474                proof: None,
17475            },
17476        );
17477        assert!(v2_riding_matches_remote(&local, &remote, |path| path == "private/local.md"));
17478        assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
17479    }
17480
17481    fn scoped_test_head(revision: &str) -> V2VerifiedHead {
17482        V2VerifiedHead {
17483            requested: TEST_BRAIN_ID.to_string(),
17484            brain_id: TEST_BRAIN_ID.to_string(),
17485            view_kind: "scoped".to_string(),
17486            view_revision: revision.to_string(),
17487            control_revision: revision.to_string(),
17488            identity: V2HeadIdentity {
17489                custody: "hub".to_string(),
17490                fingerprint: "test".to_string(),
17491                public_key_spki: "test".to_string(),
17492                previous: Vec::new(),
17493                rotations: Vec::new(),
17494            },
17495            pointer: None,
17496            trust: TrustState {
17497                v: 2,
17498                origin: "https://hub.example".to_string(),
17499                requested: TEST_BRAIN_ID.to_string(),
17500                brain: TEST_BRAIN_ID.to_string(),
17501                home: None,
17502                anchor: "ed25519:test".to_string(),
17503                current: "ed25519:test".to_string(),
17504                head_seq: 0,
17505                feed_hash: None,
17506                rotations: Vec::new(),
17507                hub_signer: None,
17508                protocol_profile: Some("link-v2".to_string()),
17509            },
17510            alias: None,
17511        }
17512    }
17513
17514    #[test]
17515    fn accepted_v2_checkpoint_never_downgrades_after_profile_migration() {
17516        let mut trust = scoped_test_head(&"a".repeat(64)).trust;
17517        assert!(accepted_as_v2(&trust));
17518
17519        trust.protocol_profile = None;
17520        trust.hub_signer = Some("ed25519:hub".to_string());
17521        assert!(accepted_as_v2(&trust));
17522
17523        trust.hub_signer = None;
17524        assert!(!accepted_as_v2(&trust));
17525    }
17526
17527    fn scoped_test_baseline(revision: &str) -> V2SyncBaseline {
17528        V2SyncBaseline {
17529            v: 2,
17530            origin: "https://hub.example".to_string(),
17531            brain: TEST_BRAIN_ID.to_string(),
17532            checkout_id: Some("c".repeat(64)),
17533            head_seq: Some(0),
17534            commit_hash: None,
17535            content_root: None,
17536            asset_root: None,
17537            assets: std::collections::BTreeMap::new(),
17538            view_kind: Some("scoped".to_string()),
17539            view_revision: Some(revision.to_string()),
17540            projection_sha256: Some(scoped_projection_sha256(TEST_BRAIN_ID)),
17541            files: std::collections::BTreeMap::new(),
17542            local_policy_digest: None,
17543            local_eligibility: std::collections::BTreeMap::new(),
17544            remote_copy_remains: std::collections::BTreeMap::new(),
17545        }
17546    }
17547
17548    #[test]
17549    fn scoped_projection_is_a_valid_local_store_marker_but_never_rides() {
17550        let directory = tempfile::tempdir().unwrap();
17551        std::fs::write(
17552            directory.path().join("DB.md"),
17553            scoped_projection_bytes(TEST_BRAIN_ID),
17554        )
17555        .unwrap();
17556        let store = Store::open_strict(directory.path()).unwrap();
17557        let head = scoped_test_head(&"a".repeat(64));
17558        let baseline = scoped_test_baseline(&"a".repeat(64));
17559        let mut view = v2_local_files(&store).unwrap();
17560        remove_scoped_projection(&head, Some(&baseline), &mut view).unwrap();
17561        assert!(!view.riding.contains_key("DB.md"));
17562        assert!(!view.eligibility.contains_key("DB.md"));
17563    }
17564
17565    #[test]
17566    fn scoped_push_accepts_a_pull_verified_handoff_without_double_checking_projection() {
17567        let directory = tempfile::tempdir().unwrap();
17568        std::fs::write(
17569            directory.path().join("DB.md"),
17570            scoped_projection_bytes(TEST_BRAIN_ID),
17571        )
17572        .unwrap();
17573        let store = Store::open_strict(directory.path()).unwrap();
17574        let head = scoped_test_head(&"a".repeat(64));
17575        let baseline = scoped_test_baseline(&"a".repeat(64));
17576
17577        let mut carried = v2_local_files(&store).unwrap();
17578        remove_scoped_projection(&head, Some(&baseline), &mut carried).unwrap();
17579        let handed_off =
17580            local_view_for_v2_push(&store, &head, Some(&baseline), Some(carried)).unwrap();
17581        assert!(!handed_off.riding.contains_key("DB.md"));
17582
17583        let freshly_scanned = local_view_for_v2_push(&store, &head, Some(&baseline), None).unwrap();
17584        assert!(!freshly_scanned.riding.contains_key("DB.md"));
17585
17586        std::fs::write(
17587            directory.path().join("DB.md"),
17588            b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
17589        )
17590        .unwrap();
17591        let tampered = Store::open_strict(directory.path()).unwrap();
17592        assert!(matches!(
17593            local_view_for_v2_push(&tampered, &head, Some(&baseline), None),
17594            Err(LinkError::ScopedProjectionModified)
17595        ));
17596    }
17597
17598    #[test]
17599    fn v2_local_view_reports_only_exact_linked_kept_home_markdown() {
17600        let directory = tempfile::tempdir().unwrap();
17601        std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
17602        std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
17603        std::fs::write(
17604            directory.path().join("DB.md"),
17605            b"---\nname: Kept home test\n---\n",
17606        )
17607        .unwrap();
17608        std::fs::write(
17609            directory.path().join("records/notes/a.md"),
17610            b"---\ntype: note\n---\nSee [[sources/private/secret]].\n",
17611        )
17612        .unwrap();
17613        std::fs::write(
17614            directory.path().join("sources/private/secret.md"),
17615            b"---\ntype: note\n---\nlocal only\n",
17616        )
17617        .unwrap();
17618        std::fs::write(
17619            directory.path().join("sources/private/unlinked.md"),
17620            b"---\ntype: note\n---\nnot disclosed\n",
17621        )
17622        .unwrap();
17623        std::fs::write(
17624            directory.path().join(".sevralocal"),
17625            b"sources/private/**\n",
17626        )
17627        .unwrap();
17628
17629        let store = Store::open_strict(directory.path()).unwrap();
17630        let view = v2_local_files(&store).unwrap();
17631        assert!(!view.riding.contains_key("sources/private/secret.md"));
17632        assert_eq!(
17633            view.withheld_links,
17634            vec![V2WithheldLink {
17635                source: "records/notes/a.md".to_string(),
17636                target: "sources/private/secret.md".to_string(),
17637            }]
17638        );
17639    }
17640
17641    #[test]
17642    fn a_kept_home_target_is_withheld_even_when_this_machine_lacks_it() {
17643        // An export omits kept-home content by construction, so a store
17644        // restored from one links to names it does not carry. Without this the
17645        // owner's own export could never be pushed back — the links would read
17646        // as broken and the commit would be refused.
17647        let directory = tempfile::tempdir().unwrap();
17648        std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
17649        std::fs::write(
17650            directory.path().join("DB.md"),
17651            b"---\nname: Restored export\n---\n",
17652        )
17653        .unwrap();
17654        std::fs::write(
17655            directory.path().join("records/notes/a.md"),
17656            b"---\ntype: note\n---\nSee [[sources/private/absent]].\n",
17657        )
17658        .unwrap();
17659        std::fs::write(
17660            directory.path().join(".sevralocal"),
17661            b"sources/private/**\n",
17662        )
17663        .unwrap();
17664
17665        let store = Store::open_strict(directory.path()).unwrap();
17666        let view = v2_local_files(&store).unwrap();
17667        assert_eq!(
17668            view.withheld_links,
17669            vec![V2WithheldLink {
17670                source: "records/notes/a.md".to_string(),
17671                target: "sources/private/absent.md".to_string(),
17672            }]
17673        );
17674        // A dangling link the owner never claimed is still dangling.
17675        std::fs::write(
17676            directory.path().join("records/notes/b.md"),
17677            b"---\ntype: note\n---\nSee [[records/notes/nowhere]].\n",
17678        )
17679        .unwrap();
17680        let store = Store::open_strict(directory.path()).unwrap();
17681        let view = v2_local_files(&store).unwrap();
17682        assert!(
17683            !view
17684                .withheld_links
17685                .iter()
17686                .any(|link| link.target == "records/notes/nowhere.md"),
17687            "an unclaimed dangling target must not be declared withheld"
17688        );
17689    }
17690
17691    #[test]
17692    fn explicit_content_withdrawal_requires_exact_current_kept_home_bytes() {
17693        let directory = tempfile::tempdir().unwrap();
17694        std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
17695        std::fs::write(
17696            directory.path().join("DB.md"),
17697            b"---\nname: Withdrawal test\n---\n",
17698        )
17699        .unwrap();
17700        let source = b"---\ntype: note\n---\nlocal evidence\n";
17701        std::fs::write(directory.path().join("sources/private/evidence.md"), source).unwrap();
17702        std::fs::write(
17703            directory.path().join(".sevralocal"),
17704            b"sources/private/**\n",
17705        )
17706        .unwrap();
17707        let store = Store::open_strict(directory.path()).unwrap();
17708        let view = v2_local_files(&store).unwrap();
17709        let mut remote = std::collections::BTreeMap::new();
17710        remote.insert(
17711            "sources/private/evidence.md".to_string(),
17712            V2BaselineFile {
17713                sha256: content_sha256(source),
17714                bytes: source.len() as u64,
17715                proof: None,
17716            },
17717        );
17718        assert_eq!(
17719            v2_content_withdrawal_operation(
17720                &store,
17721                &view,
17722                &remote,
17723                "sources/private/evidence.md",
17724                "approved retention change",
17725            )
17726            .unwrap(),
17727            json!({
17728                "op": "withdraw_from_hosting",
17729                "path": "sources/private/evidence.md",
17730                "expected": { "kind": "blob", "hash": content_sha256(source) },
17731                "reason": "approved retention change",
17732            })
17733        );
17734
17735        std::fs::write(
17736            directory.path().join("sources/private/evidence.md"),
17737            b"changed after review",
17738        )
17739        .unwrap();
17740        assert!(matches!(
17741            v2_content_withdrawal_operation(
17742                &store,
17743                &view,
17744                &remote,
17745                "sources/private/evidence.md",
17746                "approved retention change",
17747            ),
17748            Err(LinkError::InvalidPack { .. })
17749        ));
17750    }
17751
17752    #[test]
17753    fn explicit_asset_withdrawal_requires_the_exact_hosted_leaf_and_local_bytes() {
17754        let directory = tempfile::tempdir().unwrap();
17755        std::fs::create_dir_all(directory.path().join("sources/files")).unwrap();
17756        std::fs::write(
17757            directory.path().join("DB.md"),
17758            b"---\nname: Asset withdrawal test\n---\n",
17759        )
17760        .unwrap();
17761        let bytes = b"private binary";
17762        std::fs::write(directory.path().join("sources/files/private.pdf"), bytes).unwrap();
17763        std::fs::write(
17764            directory.path().join(".sevralocal"),
17765            b"sources/files/private.pdf\n",
17766        )
17767        .unwrap();
17768        let store = Store::open_strict(directory.path()).unwrap();
17769        let view = v2_local_files(&store).unwrap();
17770        let local = crate::AssetRecord {
17771            path: "sources/files/private.pdf".to_string(),
17772            sha256: content_sha256(bytes),
17773            bytes: bytes.len() as u64,
17774            media_type: "application/pdf".to_string(),
17775            wrappers: vec!["sources/files/private.md".to_string()],
17776            required: true,
17777        };
17778        let current = V2BaselineAsset {
17779            blob_sha256: local.sha256.clone(),
17780            bytes: local.bytes,
17781            media_type: local.media_type.clone(),
17782            wrappers: local.wrappers.clone(),
17783            required: local.required,
17784            disposition: "hosted".to_string(),
17785            leaf_hash: "d".repeat(64),
17786        };
17787        assert_eq!(
17788            v2_asset_withdrawal_operation(
17789                &store,
17790                &view,
17791                &local.path,
17792                &local,
17793                &current,
17794                "approved retention change",
17795            )
17796            .unwrap(),
17797            json!({
17798                "op": "asset_withdraw",
17799                "path": local.path,
17800                "expected": { "kind": "asset", "hash": "d".repeat(64) },
17801                "reason": "approved retention change",
17802            })
17803        );
17804
17805        let mut mismatched = current.clone();
17806        mismatched.required = false;
17807        assert!(matches!(
17808            v2_asset_withdrawal_operation(
17809                &store,
17810                &view,
17811                &local.path,
17812                &local,
17813                &mismatched,
17814                "approved retention change",
17815            ),
17816            Err(LinkError::InvalidPack { .. })
17817        ));
17818    }
17819
17820    #[test]
17821    fn checkout_pseudonym_is_random_then_stable_from_private_baseline() {
17822        let first = v2_checkout_id(None).unwrap();
17823        assert_eq!(first, v2_checkout_id(Some(&first)).unwrap());
17824        assert_ne!(first, v2_checkout_id(None).unwrap());
17825        assert!(is_sha256(&first));
17826    }
17827
17828    #[test]
17829    fn scoped_projection_edit_and_scope_transition_fail_closed() {
17830        let directory = tempfile::tempdir().unwrap();
17831        std::fs::write(
17832            directory.path().join("DB.md"),
17833            b"---\ntype: db-md\nscope: company\nowner: someone\n---\n",
17834        )
17835        .unwrap();
17836        let store = Store::open_strict(directory.path()).unwrap();
17837        let head = scoped_test_head(&"a".repeat(64));
17838        let baseline = scoped_test_baseline(&"a".repeat(64));
17839        let mut view = v2_local_files(&store).unwrap();
17840        assert!(matches!(
17841            remove_scoped_projection(&head, Some(&baseline), &mut view),
17842            Err(LinkError::ScopedProjectionModified)
17843        ));
17844
17845        let changed = scoped_test_head(&"b".repeat(64));
17846        assert!(matches!(
17847            ensure_v2_view_compatible(&changed, Some(&baseline)),
17848            Err(LinkError::ScopedViewChanged)
17849        ));
17850
17851        let mut same_view_new_control = head.clone();
17852        same_view_new_control.control_revision = "c".repeat(64);
17853        assert!(ensure_v2_view_compatible(&same_view_new_control, Some(&baseline)).is_ok());
17854        assert!(!same_v2_head(&head, &same_view_new_control));
17855    }
17856
17857    #[test]
17858    fn established_checkout_never_becomes_an_empty_clone_when_db_md_is_invalid() {
17859        let scoped = scoped_test_head(&"a".repeat(64));
17860        let scoped_baseline = scoped_test_baseline(&"a".repeat(64));
17861        assert!(matches!(
17862            ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), false),
17863            Err(LinkError::ScopedProjectionModified)
17864        ));
17865
17866        let mut full = scoped.clone();
17867        full.view_kind = "full".to_string();
17868        let mut full_baseline = scoped_baseline.clone();
17869        full_baseline.view_kind = Some("full".to_string());
17870        full_baseline.projection_sha256 = None;
17871        assert!(matches!(
17872            ensure_established_v2_checkout_opened(&full, Some(&full_baseline), false),
17873            Err(LinkError::InvalidPack { .. })
17874        ));
17875
17876        assert!(ensure_established_v2_checkout_opened(&scoped, None, false).is_ok());
17877        assert!(
17878            ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), true).is_ok()
17879        );
17880    }
17881
17882    #[test]
17883    fn scoped_view_metadata_is_explicitly_non_authoritative() {
17884        let head = scoped_test_head(&"a".repeat(64));
17885        let value: Value =
17886            serde_json::from_slice(&scoped_view_metadata(&head, 7).unwrap()).unwrap();
17887        assert_eq!(value["kind"], "link.md-scoped-view");
17888        assert_eq!(value["authoritative"], false);
17889        assert_eq!(value["visible_files"], 7);
17890        assert_eq!(value["brain"], TEST_BRAIN_ID);
17891    }
17892
17893    #[test]
17894    fn local_scoped_marker_requires_the_exact_generated_projection() {
17895        let directory = tempfile::tempdir().unwrap();
17896        std::fs::create_dir(directory.path().join(".dbmd")).unwrap();
17897        std::fs::write(
17898            directory.path().join("DB.md"),
17899            scoped_projection_bytes(TEST_BRAIN_ID),
17900        )
17901        .unwrap();
17902        let head = scoped_test_head(&"a".repeat(64));
17903        std::fs::write(
17904            directory.path().join(".dbmd/view.json"),
17905            scoped_view_metadata(&head, 0).unwrap(),
17906        )
17907        .unwrap();
17908        let store = Store::open_strict(directory.path()).unwrap();
17909        assert!(has_verified_local_scoped_view(&store));
17910
17911        std::fs::write(
17912            directory.path().join("DB.md"),
17913            b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
17914        )
17915        .unwrap();
17916        let altered = Store::open_strict(directory.path()).unwrap();
17917        assert!(!has_verified_local_scoped_view(&altered));
17918    }
17919
17920    fn signed_proposal_fixture() -> (V2VerifiedHead, String, Value) {
17921        use ring::signature::KeyPair as _;
17922
17923        let proposal_id = "01k2r7bm9w5x6e8nq3tjhv4cya".to_string();
17924        let rng = ring::rand::SystemRandom::new();
17925        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17926        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
17927        let public_der = [ED25519_SPKI_PREFIX.as_slice(), pair.public_key().as_ref()].concat();
17928        let public_key = URL_SAFE_NO_PAD.encode(&public_der);
17929        let fingerprint = format!("{:x}", Sha256::digest(&public_der));
17930        let blob = b"new";
17931        let blob_hash = content_sha256(blob);
17932        let changes = json!({
17933            "mutation_id": "sync:proposal-fixture",
17934            "operations": [{
17935                "blob": blob_hash,
17936                "bytes": blob.len(),
17937                "expected": null,
17938                "op": "put",
17939                "path": "records/new.md",
17940            }],
17941            "reason": "fixture",
17942            "v": 2,
17943        });
17944        let changes_bytes = crate::linkmd_v2::canonical_bytes(&changes).unwrap();
17945        let changes_base64 = STANDARD.encode(&changes_bytes);
17946        let descriptor = json!({
17947            "base": null,
17948            "blobs": [{ "bytes": blob.len(), "sha256": blob_hash }],
17949            "changes_base64": changes_base64,
17950            "rebase": "strict",
17951            "v": 2,
17952        });
17953        let clear_hash = content_sha256(&crate::linkmd_v2::canonical_bytes(&descriptor).unwrap());
17954        let payload_hash = "b".repeat(64);
17955        let submitted_at = "2026-08-19T12:00:00.000Z";
17956        let claim = json!({
17957            "actor_root": {
17958                "actor_class": "foreign_key",
17959                "credential": "ed25519:fixture",
17960                "grants": ["01k2r7bm9w5x6e8nq3tjhv4cyb"],
17961                "organization": "01k2r7bm9w5x6e8nq3tjhv4cyc",
17962                "principal": "key:fixture",
17963                "role": null,
17964            },
17965            "brain": TEST_BRAIN_ID,
17966            "clear_sha256": clear_hash,
17967            "control_revision": "c".repeat(64),
17968            "mutation_id": "sync:proposal-fixture",
17969            "payload_sha256": payload_hash,
17970            "proposal_id": proposal_id,
17971            "submitted_at": submitted_at,
17972            "v": 2,
17973        });
17974        let claim_bytes = crate::linkmd_v2::canonical_bytes(&claim).unwrap();
17975        let envelope = json!({
17976            "claim": claim,
17977            "fingerprint": fingerprint,
17978            "public_key": public_key,
17979            "sig": URL_SAFE_NO_PAD.encode(pair.sign(&claim_bytes).as_ref()),
17980        });
17981        let envelope_bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
17982        let submission_hash =
17983            crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &envelope_bytes).unwrap();
17984        let mut head = scoped_test_head(&"c".repeat(64));
17985        head.view_kind = "full".to_string();
17986        head.trust.hub_signer = Some(format!("{fingerprint}:{public_key}"));
17987        let value = json!({
17988            "proposal": {
17989                "base": null,
17990                "blobs": [{
17991                    "bytes": blob.len(),
17992                    "endpoint": format!(
17993                        "/api/hub/brains/{TEST_BRAIN_ID}/v2/proposals/{proposal_id}/blob?sha256={blob_hash}"
17994                    ),
17995                    "sha256": blob_hash,
17996                }],
17997                "changes_base64": changes_base64,
17998                "clear_sha256": clear_hash,
17999                "expires_at": "2026-08-26T12:00:00.000Z",
18000                "id": proposal_id,
18001                "payload_sha256": payload_hash,
18002                "proposer": { "class": "foreign_key" },
18003                "rebase": "strict",
18004                "state": "pending",
18005                "submission_claim_base64": STANDARD.encode(envelope_bytes),
18006                "submission_claim_sha256": submission_hash,
18007                "submitted_at": submitted_at,
18008            },
18009            "v": 2,
18010        });
18011        (head, proposal_id, value)
18012    }
18013
18014    #[test]
18015    fn v2_proposal_verifier_accepts_exact_signed_payload() {
18016        let (head, proposal_id, value) = signed_proposal_fixture();
18017        let verified = verify_v2_proposal_value(&head, &proposal_id, value).unwrap();
18018        assert_eq!(verified.blobs.len(), 1);
18019        assert_eq!(verified.changes["operations"][0]["path"], "records/new.md");
18020    }
18021
18022    #[test]
18023    fn v2_proposal_verifier_refuses_payload_endpoint_and_signature_tampering() {
18024        let (head, proposal_id, value) = signed_proposal_fixture();
18025
18026        let mut changed = value.clone();
18027        changed["proposal"]["changes_base64"] = Value::String(STANDARD.encode(b"{}"));
18028        assert!(verify_v2_proposal_value(&head, &proposal_id, changed).is_err());
18029
18030        let mut redirected = value.clone();
18031        redirected["proposal"]["blobs"][0]["endpoint"] =
18032            Value::String("https://attacker.example/blob".to_string());
18033        assert!(verify_v2_proposal_value(&head, &proposal_id, redirected).is_err());
18034
18035        let mut forged = value;
18036        let encoded = forged["proposal"]["submission_claim_base64"]
18037            .as_str()
18038            .unwrap();
18039        let mut envelope: Value =
18040            serde_json::from_slice(&STANDARD.decode(encoded).unwrap()).unwrap();
18041        envelope["sig"] = Value::String(URL_SAFE_NO_PAD.encode([0_u8; 64]));
18042        let bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
18043        forged["proposal"]["submission_claim_base64"] = Value::String(STANDARD.encode(&bytes));
18044        forged["proposal"]["submission_claim_sha256"] = Value::String(
18045            crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &bytes).unwrap(),
18046        );
18047        assert!(verify_v2_proposal_value(&head, &proposal_id, forged).is_err());
18048    }
18049
18050    #[cfg(unix)]
18051    #[test]
18052    fn v2_atomic_install_materializes_only_local_catalogs_before_swap() {
18053        let sandbox = tempfile::tempdir().unwrap();
18054        let destination = sandbox.path().join("brain");
18055        let entries = vec![
18056            (
18057                "DB.md".to_string(),
18058                scoped_projection_bytes(TEST_BRAIN_ID),
18059            ),
18060            (
18061                "records/contacts/a.md".to_string(),
18062                b"---\ntype: contact\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Visible contact\n---\n\n# A\n"
18063                    .to_vec(),
18064            ),
18065        ];
18066        install_pulled_delta(&destination, &entries, &[], true).unwrap();
18067        assert!(destination.join("index.md").is_file());
18068        assert!(destination.join("records/index.md").is_file());
18069        assert!(destination.join("records/contacts/index.md").is_file());
18070        assert!(destination.join("records/contacts/index.jsonl").is_file());
18071    }
18072
18073    #[cfg(unix)]
18074    #[test]
18075    fn v2_bounded_parallel_source_install_is_exact_and_atomic() {
18076        let sandbox = tempfile::tempdir().unwrap();
18077        let destination = sandbox.path().join("brain");
18078        let cache = sandbox.path().join("cache");
18079        std::fs::create_dir(&cache).unwrap();
18080        let db = scoped_projection_bytes(TEST_BRAIN_ID);
18081        let shared = b"---\ntype: note\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Shared\n---\n\n# Shared\n";
18082        let db_source = cache.join("db");
18083        let shared_source = cache.join("shared");
18084        crate::fsx::write_atomic(&db_source, &db).unwrap();
18085        crate::fsx::write_atomic(&shared_source, shared).unwrap();
18086        let mut entries = vec![V2StagedFile {
18087            path: "DB.md".to_string(),
18088            source: db_source,
18089            sha256: content_sha256(&db),
18090            bytes: db.len() as u64,
18091        }];
18092        for index in 0..512 {
18093            entries.push(V2StagedFile {
18094                path: format!("records/items/{index:05}.md"),
18095                source: shared_source.clone(),
18096                sha256: content_sha256(shared),
18097                bytes: shared.len() as u64,
18098            });
18099        }
18100        install_pulled_delta_sources(
18101            &destination,
18102            &entries,
18103            &[],
18104            false,
18105            None,
18106            &scoped_test_head(&"c".repeat(64)),
18107        )
18108        .unwrap();
18109        assert_eq!(std::fs::read(destination.join("DB.md")).unwrap(), db);
18110        for index in 0..512 {
18111            assert_eq!(
18112                std::fs::read(destination.join(format!("records/items/{index:05}.md"))).unwrap(),
18113                shared
18114            );
18115        }
18116        assert!(
18117            std::fs::read_dir(sandbox.path())
18118                .unwrap()
18119                .all(|entry| !entry
18120                    .unwrap()
18121                    .file_name()
18122                    .to_string_lossy()
18123                    .contains("pull-stage")),
18124            "the private stage must be atomically installed or removed"
18125        );
18126    }
18127
18128    #[cfg(unix)]
18129    #[test]
18130    fn v2_established_pull_touches_only_delta_and_rolls_back_exactly() {
18131        use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
18132
18133        let sandbox = tempfile::tempdir().unwrap();
18134        let root = sandbox.path().join("brain");
18135        std::fs::create_dir_all(root.join("records/items")).unwrap();
18136        let db = scoped_projection_bytes(TEST_BRAIN_ID);
18137        let old = b"---\ntype: note\n---\n\nold\n";
18138        let new = b"---\ntype: note\n---\n\nnew\n";
18139        let removed = b"---\ntype: note\n---\n\nremove me\n";
18140        std::fs::write(root.join("DB.md"), &db).unwrap();
18141        std::fs::write(root.join("records/items/change.md"), old).unwrap();
18142        std::fs::write(root.join("records/items/delete.md"), removed).unwrap();
18143        for index in 0..512 {
18144            std::fs::write(
18145                root.join(format!("records/items/untouched-{index:04}.md")),
18146                old,
18147            )
18148            .unwrap();
18149        }
18150        let untouched = root.join("records/items/untouched-0256.md");
18151        let untouched_inode = std::fs::metadata(&untouched).unwrap().ino();
18152        let source = sandbox.path().join("changed-source");
18153        crate::fsx::write_atomic(&source, new).unwrap();
18154        let same_source = sandbox.path().join("unchanged-source");
18155        crate::fsx::write_atomic(&same_source, old).unwrap();
18156        let same_entry = V2StagedFile {
18157            path: "records/items/change.md".to_string(),
18158            source: same_source,
18159            sha256: content_sha256(old),
18160            bytes: old.len() as u64,
18161        };
18162        let entry = V2StagedFile {
18163            path: "records/items/change.md".to_string(),
18164            source,
18165            sha256: content_sha256(new),
18166            bytes: new.len() as u64,
18167        };
18168        let head = scoped_test_head(&"c".repeat(64));
18169
18170        // A no-op established pull neither clones the checkout nor arms a
18171        // journal. Inode identity is a direct regression guard against the
18172        // former brain-wide private-copy implementation.
18173        install_established_v2_delta(
18174            Store::open_strict(&root).unwrap(),
18175            &[same_entry],
18176            &["records/items/already-absent.md".to_string()],
18177            true,
18178            None,
18179            &head,
18180        )
18181        .unwrap();
18182        assert_eq!(
18183            std::fs::metadata(&untouched).unwrap().ino(),
18184            untouched_inode
18185        );
18186        assert!(!root.join(V2_PULL_JOURNAL).exists());
18187
18188        install_established_v2_delta(
18189            Store::open_strict(&root).unwrap(),
18190            &[entry],
18191            &["records/items/delete.md".to_string()],
18192            false,
18193            None,
18194            &head,
18195        )
18196        .unwrap();
18197        assert_eq!(
18198            std::fs::read(root.join("records/items/change.md")).unwrap(),
18199            new
18200        );
18201        assert!(!root.join("records/items/delete.md").exists());
18202        assert_eq!(
18203            std::fs::metadata(&untouched).unwrap().ino(),
18204            untouched_inode
18205        );
18206        assert!(root.join(V2_PULL_JOURNAL).is_file());
18207        assert_eq!(
18208            std::fs::metadata(root.join(V2_PULL_JOURNAL))
18209                .unwrap()
18210                .permissions()
18211                .mode()
18212                & 0o777,
18213            0o600
18214        );
18215        let journal = load_v2_pull_journal(&Store::open_strict(&root).unwrap())
18216            .unwrap()
18217            .unwrap();
18218        assert_eq!(
18219            std::fs::metadata(root.join(&journal.backup_dir))
18220                .unwrap()
18221                .permissions()
18222                .mode()
18223                & 0o777,
18224            0o700
18225        );
18226        for entry in &journal.entries {
18227            if let Some(backup) = &entry.backup {
18228                assert_eq!(
18229                    std::fs::metadata(root.join(&journal.backup_dir).join(backup))
18230                        .unwrap()
18231                        .permissions()
18232                        .mode()
18233                        & 0o777,
18234                    0o600
18235                );
18236            }
18237        }
18238
18239        let cfg = test_hub_config(
18240            "https://example.test".to_string(),
18241            sandbox.path().join("state"),
18242        );
18243        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
18244        assert_eq!(
18245            std::fs::read(root.join("records/items/change.md")).unwrap(),
18246            old
18247        );
18248        assert_eq!(
18249            std::fs::read(root.join("records/items/delete.md")).unwrap(),
18250            removed
18251        );
18252        assert_eq!(
18253            std::fs::metadata(&untouched).unwrap().ino(),
18254            untouched_inode
18255        );
18256        assert!(!root.join(V2_PULL_JOURNAL).exists());
18257    }
18258
18259    #[test]
18260    fn v2_bulk_stream_is_exact_ordered_and_tamper_evident() {
18261        let body = b"bounded bytes";
18262        let path = "records/example.md".to_string();
18263        let file = V2BaselineFile {
18264            sha256: content_sha256(body),
18265            bytes: body.len() as u64,
18266            proof: None,
18267        };
18268        let header = serde_json::to_vec(&json!({
18269            "bytes": body.len(),
18270            "path": path,
18271            "sha256": file.sha256,
18272            "v": 2,
18273        }))
18274        .unwrap();
18275        let mut stream = V2_BULK_STREAM_MAGIC.to_vec();
18276        stream.extend_from_slice(&(header.len() as u32).to_be_bytes());
18277        stream.extend_from_slice(&header);
18278        stream.extend_from_slice(body);
18279        stream.extend_from_slice(&0_u32.to_be_bytes());
18280        let parsed = parse_v2_bulk_stream(&stream, &[(&path, &file)]).unwrap();
18281        assert_eq!(parsed, vec![(path.clone(), body.to_vec())]);
18282
18283        let mut tampered = stream.clone();
18284        let body_offset = V2_BULK_STREAM_MAGIC.len() + 4 + header.len();
18285        tampered[body_offset] ^= 1;
18286        assert!(parse_v2_bulk_stream(&tampered, &[(&path, &file)]).is_err());
18287
18288        let mut trailing = stream;
18289        trailing.push(0);
18290        assert!(parse_v2_bulk_stream(&trailing, &[(&path, &file)]).is_err());
18291    }
18292
18293    #[test]
18294    fn conflict_cache_prunes_only_expired_or_incomplete_state_by_default() {
18295        let sandbox = tempfile::TempDir::new().unwrap();
18296        let root = sandbox.path().join("brain");
18297        std::fs::create_dir_all(&root).unwrap();
18298        std::fs::write(
18299            root.join("DB.md"),
18300            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
18301        )
18302        .unwrap();
18303        let store = Store::open_strict(&root).unwrap();
18304        let incomplete = crate::ulid::mint();
18305        store
18306            .create_dir_all(&v2_conflict_relative(&incomplete, "files"))
18307            .unwrap();
18308        let expired = crate::ulid::mint();
18309        store
18310            .create_dir_all(&v2_conflict_relative(&expired, "files"))
18311            .unwrap();
18312        let plan = V2ConflictPlan {
18313            v: 2,
18314            class: "content_resolution_required".to_string(),
18315            bundle: expired.clone(),
18316            brain: TEST_BRAIN_ID.to_string(),
18317            origin: "https://example.test".to_string(),
18318            created_unix: 0,
18319            expires_unix: 0,
18320            base_seq: None,
18321            base_commit: None,
18322            remote_seq: 0,
18323            remote_commit: None,
18324            remote_content_root: None,
18325            view_kind: "full".to_string(),
18326            view_revision: "a".repeat(64),
18327            files: vec![V2ConflictFile {
18328                path: "records/value.md".to_string(),
18329                base: V2ConflictCoordinate {
18330                    sha256: None,
18331                    bytes: None,
18332                    file: None,
18333                },
18334                local: V2ConflictCoordinate {
18335                    sha256: None,
18336                    bytes: None,
18337                    file: None,
18338                },
18339                remote: V2ConflictCoordinate {
18340                    sha256: None,
18341                    bytes: None,
18342                    file: None,
18343                },
18344            }],
18345        };
18346        let mut bytes = serde_json::to_vec(&plan).unwrap();
18347        bytes.push(b'\n');
18348        store
18349            .write_atomic_new(&v2_conflict_relative(&expired, "plan.json"), &bytes)
18350            .unwrap();
18351
18352        let listed = sync_conflicts(&root, false, false).unwrap();
18353        assert_eq!(listed["bundles"], 2);
18354        assert_eq!(listed["pruned"], 0);
18355        let pruned = sync_conflicts(&root, true, false).unwrap();
18356        assert_eq!(pruned["bundles"], 0);
18357        assert_eq!(pruned["pruned"], 2);
18358        assert!(!root.join(".dbmd/conflicts").join(incomplete).exists());
18359        assert!(!root.join(".dbmd/conflicts").join(expired).exists());
18360    }
18361
18362    #[test]
18363    fn corrupt_completed_conflict_state_requires_explicit_discard_all() {
18364        let sandbox = tempfile::TempDir::new().unwrap();
18365        let root = sandbox.path().join("brain");
18366        std::fs::create_dir_all(&root).unwrap();
18367        std::fs::write(
18368            root.join("DB.md"),
18369            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
18370        )
18371        .unwrap();
18372        let store = Store::open_strict(&root).unwrap();
18373        let bundle = crate::ulid::mint();
18374        store
18375            .create_dir_all(&v2_conflict_relative(&bundle, "files"))
18376            .unwrap();
18377        store
18378            .write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), b"not-json\n")
18379            .unwrap();
18380
18381        assert!(sync_conflicts(&root, true, false).is_err());
18382        assert!(sync_conflicts(&root, false, true).is_err());
18383        let pruned = sync_conflicts(&root, true, true).unwrap();
18384        assert_eq!(pruned["pruned"], 1);
18385        assert!(!root.join(".dbmd/conflicts").join(bundle).exists());
18386    }
18387
18388    #[test]
18389    fn ready_pull_journal_rolls_back_exact_preimages() {
18390        let sandbox = tempfile::TempDir::new().unwrap();
18391        let root = sandbox.path().join("brain");
18392        std::fs::create_dir_all(root.join("records")).unwrap();
18393        std::fs::write(
18394            root.join("DB.md"),
18395            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
18396        )
18397        .unwrap();
18398        let path = "records/value.md";
18399        let old = b"---\ntype: note\n---\n\nold\n";
18400        let new = b"---\ntype: note\n---\n\nnew\n";
18401        std::fs::write(root.join(path), old).unwrap();
18402        let store = Store::open_strict(&root).unwrap();
18403        let bundle = crate::ulid::mint();
18404        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
18405        store
18406            .create_private_dir_all(Path::new(&backup_dir))
18407            .unwrap();
18408        store
18409            .write_private_atomic_new(&Path::new(&backup_dir).join("00000000"), old)
18410            .unwrap();
18411        let journal = V2PullJournal {
18412            v: 1,
18413            phase: V2PullPhase::Ready,
18414            brain: TEST_BRAIN_ID.to_string(),
18415            previous: V2PullCoordinate {
18416                head_seq: None,
18417                commit_hash: None,
18418                view_kind: None,
18419                view_revision: None,
18420            },
18421            next: V2PullCoordinate {
18422                head_seq: Some(2),
18423                commit_hash: Some("c".repeat(64)),
18424                view_kind: Some("full".to_string()),
18425                view_revision: Some("d".repeat(64)),
18426            },
18427            backup_dir: backup_dir.clone(),
18428            entries: vec![V2PullJournalEntry {
18429                path: path.to_string(),
18430                old: Some(V2PullFileCoordinate {
18431                    sha256: content_sha256(old),
18432                    bytes: old.len() as u64,
18433                }),
18434                new: Some(V2PullFileCoordinate {
18435                    sha256: content_sha256(new),
18436                    bytes: new.len() as u64,
18437                }),
18438                backup: Some("00000000".to_string()),
18439            }],
18440        };
18441        validate_v2_pull_journal(&journal).unwrap();
18442        store
18443            .write_private_atomic_new(
18444                Path::new(V2_PULL_JOURNAL),
18445                &v2_pull_journal_bytes(&journal).unwrap(),
18446            )
18447            .unwrap();
18448        store.write_atomic(Path::new(path), new).unwrap();
18449
18450        let cfg = test_hub_config(
18451            "https://example.test".to_string(),
18452            sandbox.path().join("state"),
18453        );
18454        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
18455        assert_eq!(std::fs::read(root.join(path)).unwrap(), old);
18456        assert!(!root.join(V2_PULL_JOURNAL).exists());
18457        assert!(!root.join(backup_dir).exists());
18458    }
18459
18460    #[test]
18461    fn preparing_pull_journal_discards_only_private_staging() {
18462        let sandbox = tempfile::TempDir::new().unwrap();
18463        let root = sandbox.path().join("brain");
18464        std::fs::create_dir_all(root.join(".dbmd")).unwrap();
18465        std::fs::write(
18466            root.join("DB.md"),
18467            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
18468        )
18469        .unwrap();
18470        let store = Store::open_strict(&root).unwrap();
18471        let bundle = crate::ulid::mint();
18472        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
18473        store
18474            .create_private_dir_all(Path::new(&backup_dir))
18475            .unwrap();
18476        let journal = V2PullJournal {
18477            v: 1,
18478            phase: V2PullPhase::Preparing,
18479            brain: TEST_BRAIN_ID.to_string(),
18480            previous: V2PullCoordinate {
18481                head_seq: None,
18482                commit_hash: None,
18483                view_kind: None,
18484                view_revision: None,
18485            },
18486            next: V2PullCoordinate {
18487                head_seq: Some(1),
18488                commit_hash: Some("a".repeat(64)),
18489                view_kind: Some("full".to_string()),
18490                view_revision: Some("b".repeat(64)),
18491            },
18492            backup_dir: backup_dir.clone(),
18493            entries: vec![V2PullJournalEntry {
18494                path: "records/new.md".to_string(),
18495                old: None,
18496                new: Some(V2PullFileCoordinate {
18497                    sha256: "c".repeat(64),
18498                    bytes: 1,
18499                }),
18500                backup: None,
18501            }],
18502        };
18503        store
18504            .write_private_atomic_new(
18505                Path::new(V2_PULL_JOURNAL),
18506                &v2_pull_journal_bytes(&journal).unwrap(),
18507            )
18508            .unwrap();
18509        let cfg = test_hub_config(
18510            "https://example.test".to_string(),
18511            sandbox.path().join("state"),
18512        );
18513
18514        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
18515
18516        assert!(root.join("DB.md").is_file());
18517        assert!(!root.join(V2_PULL_JOURNAL).exists());
18518        assert!(!root.join(backup_dir).exists());
18519    }
18520
18521    #[test]
18522    fn committed_baseline_keeps_installed_bytes_and_prunes_orphans() {
18523        let sandbox = tempfile::TempDir::new().unwrap();
18524        let root = sandbox.path().join("brain");
18525        std::fs::create_dir_all(root.join("records")).unwrap();
18526        std::fs::write(
18527            root.join("DB.md"),
18528            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
18529        )
18530        .unwrap();
18531        let new = b"---\ntype: note\n---\n\nnew\n";
18532        std::fs::write(root.join("records/value.md"), new).unwrap();
18533        let store = Store::open_strict(&root).unwrap();
18534        let bundle = crate::ulid::mint();
18535        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
18536        store
18537            .create_private_dir_all(Path::new(&backup_dir))
18538            .unwrap();
18539        let orphan = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
18540        store.create_private_dir_all(Path::new(&orphan)).unwrap();
18541        let next = V2PullCoordinate {
18542            head_seq: Some(2),
18543            commit_hash: Some("c".repeat(64)),
18544            view_kind: Some("full".to_string()),
18545            view_revision: Some("d".repeat(64)),
18546        };
18547        let journal = V2PullJournal {
18548            v: 1,
18549            phase: V2PullPhase::Ready,
18550            brain: TEST_BRAIN_ID.to_string(),
18551            previous: V2PullCoordinate {
18552                head_seq: Some(1),
18553                commit_hash: Some("a".repeat(64)),
18554                view_kind: Some("full".to_string()),
18555                view_revision: Some("b".repeat(64)),
18556            },
18557            next: next.clone(),
18558            backup_dir: backup_dir.clone(),
18559            entries: vec![V2PullJournalEntry {
18560                path: "records/value.md".to_string(),
18561                old: Some(V2PullFileCoordinate {
18562                    sha256: "e".repeat(64),
18563                    bytes: new.len() as u64,
18564                }),
18565                new: Some(V2PullFileCoordinate {
18566                    sha256: content_sha256(new),
18567                    bytes: new.len() as u64,
18568                }),
18569                backup: Some("00000000".to_string()),
18570            }],
18571        };
18572        store
18573            .write_private_atomic_new(
18574                Path::new(V2_PULL_JOURNAL),
18575                &v2_pull_journal_bytes(&journal).unwrap(),
18576            )
18577            .unwrap();
18578        let cfg = test_hub_config(
18579            "https://example.test".to_string(),
18580            sandbox.path().join("state"),
18581        );
18582        save_v2_baseline(
18583            &cfg,
18584            TEST_BRAIN_ID,
18585            &root,
18586            &V2SyncBaseline {
18587                v: 2,
18588                origin: "https://example.test".to_string(),
18589                brain: TEST_BRAIN_ID.to_string(),
18590                checkout_id: Some("c".repeat(64)),
18591                head_seq: next.head_seq,
18592                commit_hash: next.commit_hash.clone(),
18593                content_root: Some("f".repeat(64)),
18594                asset_root: None,
18595                assets: Default::default(),
18596                view_kind: next.view_kind.clone(),
18597                view_revision: next.view_revision.clone(),
18598                projection_sha256: None,
18599                files: Default::default(),
18600                local_policy_digest: None,
18601                local_eligibility: Default::default(),
18602                remote_copy_remains: Default::default(),
18603            },
18604        )
18605        .unwrap();
18606
18607        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
18608
18609        assert_eq!(std::fs::read(root.join("records/value.md")).unwrap(), new);
18610        assert!(!root.join(V2_PULL_JOURNAL).exists());
18611        assert!(!root.join(backup_dir).exists());
18612        assert!(!root.join(orphan).exists());
18613    }
18614}