Skip to main content

boatramp_node/
project_bundle.rs

1//! The **BRPROJ project export/import bundle** — the on-disk format a
2//! `project export` writes and a `project import` reads.
3//!
4//! A bundle is a plain `tar` whose FIRST entry is a JSON `MANIFEST` describing every
5//! other member, followed by the per-engine payloads:
6//!
7//! ```text
8//! MANIFEST                               magic "BRPROJ" | format_version | created_at | data_only | members[]
9//! kv.brkvdump                            the control-plane KV logical slice (kv_dump::export_to_bytes bytes)
10//! sql/<db>.pgc                           a streamed Postgres dump (pg_dump custom/plain)
11//! libsql/<project>/<site>/<db>.sqlite    a VACUUM INTO snapshot of a libsql database
12//! blobs/<...>                            blob-store objects
13//! ```
14//!
15//! Discipline borrowed from [`boatramp_core::kv_dump`]: a loud **magic** (`BRPROJ`) and
16//! an explicit **format version** the reader refuses if unknown (never misreads a
17//! foreign/newer file), plus a per-member **SHA-256 checksum** the reader VERIFIES on
18//! extraction (byte-integrity, not a size check) — so a truncated/corrupt bundle is
19//! caught before a single row is restored.
20//!
21//! The writer takes members already **staged on disk** (the multi-GB `pg_dump` stream
22//! lands in a staging file via `run_ephemeral`'s streaming sink, the libsql `VACUUM
23//! INTO` writes a file, the KV slice + blobs are staged too), so each member is appended
24//! with a known size — correctness over the PAX unknown-length streaming form, and the
25//! staging keeps memory bounded regardless of dump size. All IO is synchronous (the
26//! `tar` crate is sync); the async driver calls it from a blocking task.
27
28use std::collections::HashSet;
29use std::io::{self, Read, Write};
30use std::path::{Path, PathBuf};
31
32use serde::{Deserialize, Serialize};
33use sha2::{Digest, Sha256};
34
35/// The bundle magic — carried in the MANIFEST so a stray/foreign tar is rejected loud.
36pub const BUNDLE_MAGIC: &str = "BRPROJ";
37
38/// The bundle FORMAT version. Bumped only on an incompatible change; a reader refuses an
39/// unknown version rather than misreading it.
40pub const BUNDLE_FORMAT_VERSION: u32 = 1;
41
42/// The manifest's tar-entry path — MUST be the first entry in the bundle.
43pub const MANIFEST_PATH: &str = "MANIFEST";
44
45/// The control-plane KV slice's tar-entry path (the `kv_dump::export_to_bytes` bytes).
46pub const KV_MEMBER_PATH: &str = "kv.brkvdump";
47
48/// The control-plane KV **delta** slice's member path (a DELTA bundle only — the
49/// `boatramp_core::project_delta::encode_kv_delta` bytes: upserts + tombstones).
50pub const KV_DELTA_MEMBER_PATH: &str = "kv.brkvdelta";
51
52/// The blob **tombstone** list's member path (a DELTA bundle only — the
53/// `boatramp_core::project_delta::encode_blob_tombstones` bytes).
54pub const BLOB_TOMBSTONE_MEMBER_PATH: &str = "blobs.brtombstones";
55
56/// The identity transport-container's tar-entry path (`--include-identity` only): the
57/// passphrase-wrapped KEK, produced by `boatramp_server::envelope::transport_wrap`.
58pub const IDENTITY_MEMBER_PATH: &str = "identity/identity.brid";
59
60/// Which engine a bundle member came from.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "snake_case")]
63pub enum MemberKind {
64    /// A Postgres `pg_dump` (under `sql/<db>.pgc`).
65    PgDump,
66    /// A MySQL `mysqldump` (under `sql/<db>.sql`).
67    MysqlDump,
68    /// A libsql `VACUUM INTO` snapshot (under `libsql/<project>/<site>/<db>.sqlite`).
69    Libsql,
70    /// The control-plane KV logical slice (`kv.brkvdump`).
71    Kv,
72    /// A blob-store object (under `blobs/<...>`).
73    Blob,
74    /// The passphrase-wrapped identity transport container (`identity/identity.brid`),
75    /// present only under `--include-identity`. Carries the project's KEK (transport-wrapped)
76    /// so an `--identity adopt` (recovery) can unseal the KV slice's sealed secrets.
77    Identity,
78    /// A control-plane KV **delta** slice (`kv.brkvdelta`) — present only in a DELTA bundle: the
79    /// changed/new KV upserts + the deleted-key tombstones since the base (the
80    /// `boatramp_core::project_delta` framing). Replaces the full `Kv` member in a delta.
81    KvDelta,
82    /// A blob **tombstone** list (`blobs.brtombstones`) — present only in a DELTA bundle: the blob
83    /// keys deleted since the base (deletes-as-tombstones). The blobs that were ADDED/CHANGED ride
84    /// as ordinary `Blob` members (adds-as-full-members).
85    BlobTombstone,
86    /// A Postgres logical-WAL **delta** (`sql/<db>.pgwal`) — present only in a TRUE-delta bundle: the
87    /// decoded change stream since the member's `sql_cursor` LSN, replayed onto the restored base.
88    /// (SQL true-delta program; replaces the full `PgDump` member for that DB when a true-delta was
89    /// captured.)
90    PgWalDelta,
91    /// A MySQL binlog **delta** (`sql/<db>.mysqlbinlog`) — the binlog events since the member's
92    /// `sql_cursor` GTID, replayed onto the restored base.
93    MysqlBinlogDelta,
94    /// A libsql/SQLite **changeset** (`libsql/<project>/<site>/<db>.changeset`) — the row-level
95    /// changeset diffed against the base snapshot named by the member's `sql_cursor`, applied onto
96    /// the restored base.
97    LibsqlChangeset,
98}
99
100/// SQL-dump provenance for a `PgDump`/`MysqlDump` member: the detected server version,
101/// the client image used to produce (and required to restore) it, and the schema-ledger
102/// fingerprint for the import schema-skew check. (Field named `pg` historically; it
103/// carries MySQL provenance too — the engine is the member's [`MemberKind`].)
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct PgMemberInfo {
106    /// The source server's version banner (`SHOW server_version_num` for Postgres,
107    /// `SELECT VERSION()` for MySQL).
108    pub server_version_num: String,
109    /// The client image the dump was produced with (`pgvector/pgvector:pg<major>` or
110    /// `mysql:<maj.min>`) — an import MUST use a client whose version is >= this (the
111    /// version-match invariant).
112    pub client_image: String,
113    /// A fingerprint of the source's `boatramp_migrations` ledger, so a `--data-only`
114    /// import can REFUSE a schema-skewed target (its migrated schema differs from what
115    /// the data expects) unless the operator overrides. `None` when not captured.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub schema_fingerprint: Option<String>,
118}
119
120/// One member described in the MANIFEST.
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122pub struct ManifestMember {
123    /// The member's tar-entry path (relative, no traversal).
124    pub path: String,
125    /// Which engine produced it.
126    pub kind: MemberKind,
127    /// The member's exact byte length.
128    pub size: u64,
129    /// Hex SHA-256 of the member bytes — verified on extraction.
130    pub sha256: String,
131    /// Postgres provenance (only for `PgDump` members).
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub pg: Option<PgMemberInfo>,
134    /// The base CURSOR for a TRUE-delta SQL member (`PgWalDelta`/`MysqlBinlogDelta`/
135    /// `LibsqlChangeset`) — the point the delta was captured since, so restore replays from there.
136    /// `None` for every other member (incl. a full-dump SQL member in the all-families fallback).
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub sql_cursor: Option<boatramp_core::project_delta::SqlMemberCursor>,
139}
140
141/// What the bundle was exported from.
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143pub struct SourceInfo {
144    /// The source project.
145    pub project: String,
146    /// The source site scope, when the export was site-scoped (`None` ⇒ project-level).
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub site: Option<String>,
149}
150
151/// The bundle MANIFEST — the first tar entry, describing the whole bundle.
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153pub struct Manifest {
154    /// Must equal [`BUNDLE_MAGIC`] — a loud reject of a non-boatramp tar.
155    pub magic: String,
156    /// The bundle format version (see [`BUNDLE_FORMAT_VERSION`]).
157    pub format_version: u32,
158    /// Unix seconds when the bundle was produced.
159    pub created_at: u64,
160    /// Whether this is a `--data-only` bundle (app data only — no identity/RBAC/routing).
161    pub data_only: bool,
162    /// What the bundle was exported from.
163    pub source: SourceInfo,
164    /// Every non-manifest member, in write order.
165    pub members: Vec<ManifestMember>,
166}
167
168/// A project-bundle error.
169#[derive(Debug, thiserror::Error)]
170pub enum BundleError {
171    /// An underlying IO error (tar read/write, staging-file read, extraction write).
172    #[error("bundle io: {0}")]
173    Io(#[from] io::Error),
174    /// The MANIFEST JSON failed to (de)serialize.
175    #[error("bundle manifest json: {0}")]
176    Json(#[from] serde_json::Error),
177    /// The first entry's magic did not match — not a boatramp project bundle.
178    #[error("not a boatramp project bundle: expected magic {expected:?}, found {found:?}")]
179    BadMagic {
180        /// The expected magic ([`BUNDLE_MAGIC`]).
181        expected: String,
182        /// What the bundle carried.
183        found: String,
184    },
185    /// The bundle format version is not one this build reads.
186    #[error("unsupported bundle format version {found} (this build reads {supported})")]
187    BadVersion {
188        /// The version the bundle declared.
189        found: u32,
190        /// The version this build supports ([`BUNDLE_FORMAT_VERSION`]).
191        supported: u32,
192    },
193    /// The bundle's first entry was not the MANIFEST.
194    #[error("bundle is missing its {0:?} entry as the first entry")]
195    MissingManifest(String),
196    /// A member's extracted bytes did not match the manifest checksum.
197    #[error("checksum MISMATCH for member {path:?}: manifest {expected}, extracted {actual}")]
198    ChecksumMismatch {
199        /// The offending member path.
200        path: String,
201        /// The checksum the manifest recorded.
202        expected: String,
203        /// The checksum of the bytes actually in the bundle.
204        actual: String,
205    },
206    /// A member named in the manifest was absent from the bundle.
207    #[error("member {0:?} named in the manifest is absent from the bundle")]
208    MissingMember(String),
209    /// A member present in the bundle was not described by the manifest.
210    #[error("member {0:?} is present in the bundle but absent from the manifest")]
211    UnexpectedMember(String),
212    /// A member path is unsafe (absolute or contains a `..`/`.` traversal component).
213    #[error("unsafe member path {0:?} in bundle (absolute or traversal)")]
214    UnsafePath(String),
215    /// A v2 bundle is structurally malformed — a sidecar without its data member, a member
216    /// without its preceding sidecar, a mismatched pairing, or a member out of declared order.
217    #[error("malformed v2 bundle stream: {0}")]
218    MalformedStream(String),
219    /// A member's streamed byte count did not match the size it declared (sidecar/manifest).
220    #[error("size MISMATCH for member {path:?}: declared {expected}, streamed {actual}")]
221    SizeMismatch {
222        /// The offending member path.
223        path: String,
224        /// The size the sidecar (v2) or manifest (v1) declared.
225        expected: u64,
226        /// The bytes actually streamed.
227        actual: u64,
228    },
229}
230
231impl Manifest {
232    /// Loud-reject a foreign or incompatible bundle: the magic and format version MUST
233    /// match this build (mirrors the `kv_dump` discipline).
234    pub fn validate(&self) -> Result<(), BundleError> {
235        if self.magic != BUNDLE_MAGIC {
236            return Err(BundleError::BadMagic {
237                expected: BUNDLE_MAGIC.to_string(),
238                found: self.magic.clone(),
239            });
240        }
241        if self.format_version != BUNDLE_FORMAT_VERSION {
242            return Err(BundleError::BadVersion {
243                found: self.format_version,
244                supported: BUNDLE_FORMAT_VERSION,
245            });
246        }
247        Ok(())
248    }
249
250    /// The `PgDump` members (one per Postgres database).
251    pub fn pg_members(&self) -> impl Iterator<Item = &ManifestMember> {
252        self.members.iter().filter(|m| m.kind == MemberKind::PgDump)
253    }
254}
255
256/// A member staged on disk, ready to be appended to a bundle.
257#[derive(Debug, Clone)]
258pub struct StagedMember {
259    /// The tar-entry path to write it at (relative, no traversal).
260    pub path: String,
261    /// Which engine produced it.
262    pub kind: MemberKind,
263    /// The on-disk staging file holding the member bytes.
264    pub source: PathBuf,
265    /// Postgres provenance (only for `PgDump` members).
266    pub pg: Option<PgMemberInfo>,
267}
268
269/// Reject an absolute or traversing member path (defense on both write and read, so a
270/// malicious bundle can never extract outside its target directory).
271fn validate_member_path(path: &str) -> Result<(), BundleError> {
272    use std::path::Component;
273    if path.is_empty() {
274        return Err(BundleError::UnsafePath(path.to_string()));
275    }
276    let p = Path::new(path);
277    if p.is_absolute() {
278        return Err(BundleError::UnsafePath(path.to_string()));
279    }
280    for c in p.components() {
281        if !matches!(c, Component::Normal(_)) {
282            return Err(BundleError::UnsafePath(path.to_string()));
283        }
284    }
285    Ok(())
286}
287
288/// Stream `r`, returning `(byte_count, hex_sha256)` without buffering it whole.
289fn size_and_sha256<R: Read>(mut r: R) -> io::Result<(u64, String)> {
290    let mut hasher = Sha256::new();
291    let mut buf = [0u8; 64 * 1024];
292    let mut total = 0u64;
293    loop {
294        let n = r.read(&mut buf)?;
295        if n == 0 {
296            break;
297        }
298        hasher.update(&buf[..n]);
299        total += n as u64;
300    }
301    Ok((total, hex::encode(hasher.finalize())))
302}
303
304/// Append `data` as a tar member at `path` (a small in-memory member, e.g. the MANIFEST).
305fn append_bytes<W: Write>(
306    builder: &mut tar::Builder<W>,
307    path: &str,
308    mtime: u64,
309    data: &[u8],
310) -> Result<(), BundleError> {
311    let mut header = tar::Header::new_gnu();
312    header.set_size(data.len() as u64);
313    header.set_mode(0o600);
314    header.set_mtime(mtime);
315    // `append_data` sets the path + checksum itself.
316    builder.append_data(&mut header, path, data)?;
317    Ok(())
318}
319
320/// Write a bundle at `out` from members already staged on disk: compute each member's
321/// size + SHA-256, build + validate the MANIFEST, then write the tar with the MANIFEST
322/// first and every member streamed from its staging file (bounded memory). Returns the
323/// MANIFEST that was written.
324pub fn write_bundle(
325    out: &Path,
326    created_at: u64,
327    data_only: bool,
328    source: SourceInfo,
329    staged: &[StagedMember],
330) -> Result<Manifest, BundleError> {
331    // 1. Describe every member (size + checksum), validating each path.
332    let mut members = Vec::with_capacity(staged.len());
333    for m in staged {
334        validate_member_path(&m.path)?;
335        let f = std::fs::File::open(&m.source)?;
336        let (size, sha256) = size_and_sha256(io::BufReader::new(f))?;
337        members.push(ManifestMember {
338            path: m.path.clone(),
339            kind: m.kind,
340            size,
341            sha256,
342            pg: m.pg.clone(),
343            // The v1 whole-staging path never emits a true-delta member (those flow through the
344            // v3 streaming writer via `MemberSpec::with_sql_cursor`), so no cursor here.
345            sql_cursor: None,
346        });
347    }
348    let manifest = Manifest {
349        magic: BUNDLE_MAGIC.to_string(),
350        format_version: BUNDLE_FORMAT_VERSION,
351        created_at,
352        data_only,
353        source,
354        members,
355    };
356    manifest.validate()?; // defensive — we just built it, but keep the one invariant honest
357
358    // 2. Write the tar: MANIFEST first, then each member streamed from its file.
359    let file = std::fs::File::create(out)?;
360    let mut builder = tar::Builder::new(io::BufWriter::new(file));
361    let manifest_json = serde_json::to_vec_pretty(&manifest)?;
362    append_bytes(&mut builder, MANIFEST_PATH, created_at, &manifest_json)?;
363    for (sm, mm) in staged.iter().zip(&manifest.members) {
364        let mut f = std::fs::File::open(&sm.source)?;
365        let mut header = tar::Header::new_gnu();
366        header.set_size(mm.size);
367        header.set_mode(0o600);
368        header.set_mtime(created_at);
369        builder.append_data(&mut header, &sm.path, &mut f)?;
370    }
371    let mut inner = builder.into_inner()?;
372    inner.flush()?;
373    Ok(manifest)
374}
375
376/// Read + validate the MANIFEST (the first tar entry) from `bundle`, without extracting
377/// any member.
378pub fn read_manifest(bundle: &Path) -> Result<Manifest, BundleError> {
379    let file = std::fs::File::open(bundle)?;
380    let mut ar = tar::Archive::new(io::BufReader::new(file));
381    let mut entries = ar.entries()?;
382    let first = entries
383        .next()
384        .ok_or_else(|| BundleError::MissingManifest(MANIFEST_PATH.to_string()))??;
385    let path = first.path()?.to_string_lossy().into_owned();
386    if path != MANIFEST_PATH {
387        return Err(BundleError::MissingManifest(MANIFEST_PATH.to_string()));
388    }
389    let mut buf = String::new();
390    let mut first = first;
391    first.read_to_string(&mut buf)?;
392    let manifest: Manifest = serde_json::from_str(&buf)?;
393    manifest.validate()?;
394    Ok(manifest)
395}
396
397/// Extract every member of `bundle` into `out_dir`, VERIFYING each member's SHA-256
398/// against the MANIFEST as it streams (byte-integrity). Refuses a member the manifest
399/// does not describe, and a manifest member the bundle does not contain. Returns the
400/// validated MANIFEST. Member files land at `out_dir/<member.path>`.
401pub fn extract_and_verify(bundle: &Path, out_dir: &Path) -> Result<Manifest, BundleError> {
402    let manifest = read_manifest(bundle)?;
403    let file = std::fs::File::open(bundle)?;
404    let mut ar = tar::Archive::new(io::BufReader::new(file));
405    let mut seen: HashSet<String> = HashSet::new();
406    for entry in ar.entries()? {
407        let mut entry = entry?;
408        let path = entry.path()?.to_string_lossy().into_owned();
409        if path == MANIFEST_PATH {
410            continue;
411        }
412        validate_member_path(&path)?;
413        let mm = manifest
414            .members
415            .iter()
416            .find(|m| m.path == path)
417            .ok_or_else(|| BundleError::UnexpectedMember(path.clone()))?;
418        let dst = out_dir.join(&path);
419        if let Some(parent) = dst.parent() {
420            std::fs::create_dir_all(parent)?;
421        }
422        // Stream the member to disk while hashing — never buffer the (multi-GB) payload.
423        let mut hasher = Sha256::new();
424        let mut f = io::BufWriter::new(std::fs::File::create(&dst)?);
425        let mut buf = [0u8; 64 * 1024];
426        loop {
427            let n = entry.read(&mut buf)?;
428            if n == 0 {
429                break;
430            }
431            hasher.update(&buf[..n]);
432            f.write_all(&buf[..n])?;
433        }
434        f.flush()?;
435        let actual = hex::encode(hasher.finalize());
436        if actual != mm.sha256 {
437            return Err(BundleError::ChecksumMismatch {
438                path,
439                expected: mm.sha256.clone(),
440                actual,
441            });
442        }
443        seen.insert(path);
444    }
445    for m in &manifest.members {
446        if !seen.contains(&m.path) {
447            return Err(BundleError::MissingMember(m.path.clone()));
448        }
449    }
450    Ok(manifest)
451}
452
453// ============================================================================
454// Format v2 — streamable export/import
455//
456// v1 blocks a streaming EXPORT: the MANIFEST (first entry) carries every member's size +
457// SHA-256, so all members must be produced (to compute those) before the MANIFEST can be
458// written — i.e. the whole bundle has to be staged first. v2 removes that coupling: the
459// MANIFEST becomes an ordered member LIST only, and each member's size + SHA-256 move into a
460// per-member `.brmeta` SIDECAR written immediately BEFORE that member. The MANIFEST now needs
461// nothing a member produces, so an export streams one member at a time with a transient
462// footprint bounded to the LARGEST SINGLE MEMBER, and an import is a single forward pass.
463// All IO here is synchronous (the `tar` crate is sync); the async driver drives it from a
464// blocking task — see the wiring handoff.
465// ============================================================================
466
467/// The **streamable** bundle format version (see the section comment above).
468///
469/// **Compatibility decision — read-compatible, write-forward.** The v2-aware single-pass
470/// reader [`read_bundle_streaming`] reads BOTH v1 and v2 bundles (it dispatches on
471/// `format_version`), so every bundle a prior release wrote still imports. The v2 writer only
472/// ever writes v2. An older node handed a v2 bundle refuses it LOUD as
473/// [`BundleError::BadVersion`] (its [`Manifest::validate`] rejects version 2) — never a
474/// silent misread. This mirrors the `kv_dump` loud-reject discipline.
475pub const BUNDLE_FORMAT_VERSION_V2: u32 = 2;
476
477/// The suffix of a member's sidecar tar entry. For a data member at `<path>`, its sidecar is
478/// `<path>.brmeta` — a tiny JSON [`MemberMeta`] (size + SHA-256) written immediately before
479/// `<path>`, so a single-pass reader learns them before the body arrives.
480pub const META_SUFFIX: &str = ".brmeta";
481
482// ============================================================================
483// Format v3 — chunked members with a TRAILING trailer (inc2)
484//
485// v2 removed the whole-bundle-staging coupling (each member's size+SHA moved to a per-member
486// leading `.brmeta` sidecar), but a member still had to be STAGED WHOLE to a temp before its
487// sidecar could be written (the sidecar carries the size+SHA, computed by reading the staged
488// temp). For an engine dump that can be many GB — a `pg_dump`/`.dump` SQL stream — that whole
489// member landed on `work_dir`, which is the env-clone disk-fill risk v2 only halved.
490//
491// v3 adds a SECOND framing, `ChunkedTrailer`, for the large/streamed kinds. Instead of a
492// leading sidecar + a single body entry, a chunked member is a run of fixed-size `.part{N}`
493// tar entries followed by a `.brtrailer` (the size+SHA, AFTER the data). The producer hashes +
494// counts WHILE streaming each chunk out (no stat, no whole-member temp); the reader hashes +
495// counts WHILE reading, comparing to the trailer at the end — O(chunk) on BOTH sides. Small
496// kinds (Identity, Kv, small blobs) stay `Sidecar` (keeping v2's verify-before-apply free).
497//
498// Read-compat is total: the v3 reader [`read_bundle_streaming_atomic`] reads v1, v2 AND v3; a
499// v2 manifest (no `framing` field) deserializes with `framing = Sidecar` via `#[serde(default)]`.
500// Write-forward: the driver emits v3 ONLY when at least one member is chunked (else v2, so a
501// bundle an inc1 node can still read stays v2); an inc1 node handed a v3 bundle loud-rejects it
502// as [`BundleError::BadVersion`] (the v1/v2 reader's final dispatch arm), never a silent misread.
503// ============================================================================
504
505/// The **chunked** bundle format version (see the section comment above). Emitted when at least
506/// one member uses [`MemberFraming::ChunkedTrailer`]; a v3 reader reads v1 + v2 + v3.
507pub const BUNDLE_FORMAT_VERSION_V3: u32 = 3;
508
509/// The suffix of a chunked member's TRAILING trailer entry. For a chunked member at `<path>`, its
510/// trailer is `<path>.brtrailer` — a tiny JSON [`MemberTrailer`] (size + SHA-256) written AFTER
511/// the member's `.part{N}` chunks, so the reader verifies integrity once the whole member has
512/// streamed (the chunked analogue of the v2 leading [`MemberMeta`] sidecar).
513pub const TRAILER_SUFFIX: &str = ".brtrailer";
514
515/// The infix of a chunked member's data-part tar entries. Chunk `N` of a member at `<path>` is the
516/// entry `<path>.part{N:05}` (zero-padded, monotonic from `00000`).
517pub const PART_INFIX: &str = ".part";
518
519/// The tar-entry path of chunk `n` of a chunked member at `base`.
520fn chunk_part_path(base: &str, n: u32) -> String {
521    format!("{base}{PART_INFIX}{n:05}")
522}
523
524/// The tar-entry path of a chunked member's trailer.
525fn chunk_trailer_path(base: &str) -> String {
526    format!("{base}{TRAILER_SUFFIX}")
527}
528
529/// How a bundle member's bytes are framed in the tar. `Sidecar` (the default, and the only v2
530/// framing) is a leading [`MemberMeta`] `.brmeta` then a single body entry; `ChunkedTrailer` is a
531/// run of `.part{N}` entries then a trailing [`MemberTrailer`] `.brtrailer` (v3).
532#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
533#[serde(rename_all = "snake_case")]
534pub enum MemberFraming {
535    /// Leading `.brmeta` sidecar + one body entry (v2 shape). The default, so a v2 manifest with
536    /// no `framing` field deserializes to this.
537    #[default]
538    Sidecar,
539    /// A run of `.part{N:05}` data entries + a trailing `.brtrailer` (v3 shape) — O(chunk) both
540    /// sides, for the large/streamed kinds (SQL dumps, large blobs).
541    ChunkedTrailer,
542}
543
544impl MemberFraming {
545    /// Whether this is the default [`Sidecar`](Self::Sidecar) framing (used by
546    /// `skip_serializing_if` so a v2/sidecar manifest stays byte-identical to the pre-v3 form).
547    fn is_sidecar(&self) -> bool {
548        matches!(self, MemberFraming::Sidecar)
549    }
550}
551
552/// The upfront-knowable identity of a bundle member — what the v2/v3 MANIFEST lists, in restore
553/// order. Carries no size/SHA (those travel in the member's [`MemberMeta`] sidecar or
554/// [`MemberTrailer`]), so the MANIFEST can be written before any member is produced.
555#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
556pub struct MemberSpec {
557    /// The member's tar-entry path (relative, no traversal).
558    pub path: String,
559    /// Which engine produced it.
560    pub kind: MemberKind,
561    /// How the member's bytes are framed (v3). Absent in a v2 manifest ⇒ [`MemberFraming::Sidecar`]
562    /// (`#[serde(default)]`), which is what makes a v2 bundle deserialize into a v3 reader for free;
563    /// a `Sidecar` member is omitted from a serialized manifest (`skip_serializing_if`) so a
564    /// no-chunked (v2) bundle's manifest is byte-identical to the pre-v3 form.
565    #[serde(default, skip_serializing_if = "MemberFraming::is_sidecar")]
566    pub framing: MemberFraming,
567    /// Postgres/MySQL provenance (only for `PgDump`/`MysqlDump` members).
568    #[serde(default, skip_serializing_if = "Option::is_none")]
569    pub pg: Option<PgMemberInfo>,
570    /// The base CURSOR for a TRUE-delta SQL member (see [`ManifestMember::sql_cursor`]); `None` for
571    /// every full member. Carried here too so the MANIFEST (written before members are produced)
572    /// already declares the replay point a reader will need.
573    #[serde(default, skip_serializing_if = "Option::is_none")]
574    pub sql_cursor: Option<boatramp_core::project_delta::SqlMemberCursor>,
575}
576
577impl MemberSpec {
578    /// A `Sidecar`-framed member spec (the common case; v2 shape).
579    pub fn sidecar(path: String, kind: MemberKind, pg: Option<PgMemberInfo>) -> Self {
580        Self {
581            path,
582            kind,
583            framing: MemberFraming::Sidecar,
584            pg,
585            sql_cursor: None,
586        }
587    }
588
589    /// A `ChunkedTrailer`-framed member spec (a large/streamed member; v3 shape).
590    pub fn chunked(path: String, kind: MemberKind, pg: Option<PgMemberInfo>) -> Self {
591        Self {
592            path,
593            kind,
594            framing: MemberFraming::ChunkedTrailer,
595            pg,
596            sql_cursor: None,
597        }
598    }
599
600    /// Attach a TRUE-delta base cursor to this member spec (builder; used by the SQL true-delta
601    /// capture when it emits a `PgWalDelta`/`MysqlBinlogDelta`/`LibsqlChangeset` member).
602    #[must_use]
603    pub fn with_sql_cursor(
604        mut self,
605        cursor: boatramp_core::project_delta::SqlMemberCursor,
606    ) -> Self {
607        self.sql_cursor = Some(cursor);
608        self
609    }
610}
611
612/// A chunked member's `.brtrailer`: the size + SHA-256 of the whole member, written AFTER its
613/// `.part{N}` chunks so a streaming producer need never stat or buffer the member (the chunked
614/// analogue of the v2 leading [`MemberMeta`]).
615#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
616pub struct MemberTrailer {
617    /// The member's exact byte length (sum of all `.part{N}` chunk bytes).
618    pub size: u64,
619    /// Hex SHA-256 of the member bytes — verified against the streamed bytes as the trailer is read.
620    pub sha256: String,
621}
622
623/// The **v2 MANIFEST** — the first tar entry: the ordered member LIST plus bundle metadata.
624/// Unlike the v1 [`Manifest`] it carries NO per-member size/SHA (each member's live in its
625/// [`MemberMeta`] sidecar), which is what makes a streaming export possible.
626#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
627pub struct StreamManifest {
628    /// Must equal [`BUNDLE_MAGIC`].
629    pub magic: String,
630    /// The bundle format version — [`BUNDLE_FORMAT_VERSION_V2`].
631    pub format_version: u32,
632    /// Unix seconds when the bundle was produced.
633    pub created_at: u64,
634    /// Whether this is a `--data-only` bundle.
635    pub data_only: bool,
636    /// What the bundle was exported from.
637    pub source: SourceInfo,
638    /// Every non-manifest member, in the order the reader must restore them.
639    pub members: Vec<MemberSpec>,
640    /// DELTA bundle metadata (the mode + base/parent reference). Absent ⇒ an ordinary FULL bundle
641    /// (so a v2/v3 full manifest round-trips byte-identically — `skip_serializing_if`), present ⇒
642    /// the restore resolves the base chain and applies this bundle's per-family deltas onto it.
643    #[serde(default, skip_serializing_if = "Option::is_none")]
644    pub delta: Option<boatramp_core::project_delta::DeltaInfo>,
645}
646
647impl StreamManifest {
648    /// Loud-reject a foreign or wrong-version bundle (magic + version MUST be v2 or v3). The one
649    /// `StreamManifest` struct serves both the v2 (sidecar-only) and v3 (some chunked) forms — a
650    /// v3 manifest differs only in `format_version = 3` and a `framing` field on its chunked
651    /// members (absent ⇒ `Sidecar`), so a v2 manifest validates and round-trips unchanged.
652    fn validate(&self) -> Result<(), BundleError> {
653        if self.magic != BUNDLE_MAGIC {
654            return Err(BundleError::BadMagic {
655                expected: BUNDLE_MAGIC.to_string(),
656                found: self.magic.clone(),
657            });
658        }
659        if self.format_version != BUNDLE_FORMAT_VERSION_V2
660            && self.format_version != BUNDLE_FORMAT_VERSION_V3
661        {
662            return Err(BundleError::BadVersion {
663                found: self.format_version,
664                supported: BUNDLE_FORMAT_VERSION_V3,
665            });
666        }
667        Ok(())
668    }
669
670    /// The sidecar tar-entry path for a member at `path`.
671    fn meta_path(path: &str) -> String {
672        format!("{path}{META_SUFFIX}")
673    }
674}
675
676/// A member's `.brmeta` sidecar: the size + SHA-256 the v1 MANIFEST used to carry inline,
677/// now written immediately before the member so the reader knows them before the body.
678#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
679pub struct MemberMeta {
680    /// The member's exact byte length.
681    pub size: u64,
682    /// Hex SHA-256 of the member bytes — verified as the member streams.
683    pub sha256: String,
684}
685
686/// A streaming v2 bundle writer: writes the MANIFEST up front, then appends members ONE AT A
687/// TIME ([`append_member`](BundleStreamWriter::append_member)), each from a bounded staging
688/// temp the caller may delete the instant the call returns — so the node never holds more
689/// than the largest single member. The export driver drives it from a blocking task, staging
690/// each member just before it appends and deleting the temp just after (see the convenience
691/// wrapper [`write_bundle_streaming`], which encapsulates exactly that produce→append→delete
692/// loop).
693pub struct BundleStreamWriter<W: Write> {
694    builder: tar::Builder<W>,
695    created_at: u64,
696    /// The members the MANIFEST declared, consumed in order as `append_member` is called, so
697    /// an out-of-order or extra append is refused and a shortfall is caught at `finish`.
698    remaining: std::collections::VecDeque<MemberSpec>,
699}
700
701impl<W: Write> BundleStreamWriter<W> {
702    /// Begin a streamable bundle on `out`, writing the MANIFEST (the ordered member list) first.
703    /// `specs` is every member in restore order; each must then be supplied, in that order, via
704    /// [`append_member`](Self::append_member) (Sidecar framing) or
705    /// [`append_member_chunked`](Self::append_member_chunked) (ChunkedTrailer framing) to match
706    /// the spec's declared framing, and the bundle closed with [`finish`](Self::finish). The
707    /// MANIFEST declares [`BUNDLE_FORMAT_VERSION_V3`] when ANY member is chunked, else
708    /// [`BUNDLE_FORMAT_VERSION_V2`] — so a bundle with no chunked member is byte-identical to the
709    /// pre-v3 v2 form (an inc1 node can still read it).
710    pub fn new(
711        out: W,
712        created_at: u64,
713        data_only: bool,
714        source: SourceInfo,
715        specs: Vec<MemberSpec>,
716    ) -> Result<Self, BundleError> {
717        for s in &specs {
718            validate_member_path(&s.path)?;
719        }
720        let format_version = if specs
721            .iter()
722            .any(|s| s.framing == MemberFraming::ChunkedTrailer)
723        {
724            BUNDLE_FORMAT_VERSION_V3
725        } else {
726            BUNDLE_FORMAT_VERSION_V2
727        };
728        let manifest = StreamManifest {
729            magic: BUNDLE_MAGIC.to_string(),
730            format_version,
731            created_at,
732            data_only,
733            source,
734            members: specs.clone(),
735            // The forward-only streaming writer never emits a delta bundle (a delta needs a
736            // re-readable base ⇒ the resumable member-object target only).
737            delta: None,
738        };
739        manifest.validate()?;
740        let mut builder = tar::Builder::new(out);
741        let manifest_json = serde_json::to_vec_pretty(&manifest)?;
742        append_bytes(&mut builder, MANIFEST_PATH, created_at, &manifest_json)?;
743        Ok(Self {
744            builder,
745            created_at,
746            remaining: specs.into_iter().collect(),
747        })
748    }
749
750    /// Pop + validate the next declared member, asserting it matches `(path, kind, framing)` — the
751    /// shared front of [`append_member`](Self::append_member) and
752    /// [`append_member_chunked`](Self::append_member_chunked). An out-of-order, unexpected, or
753    /// wrong-framing append is refused loud.
754    fn next_expected(
755        &mut self,
756        path: &str,
757        kind: MemberKind,
758        framing: MemberFraming,
759    ) -> Result<(), BundleError> {
760        let expected = self.remaining.pop_front().ok_or_else(|| {
761            BundleError::MalformedStream(format!(
762                "append of {path:?} but the MANIFEST declared no more members"
763            ))
764        })?;
765        if expected.path != path || expected.kind != kind || expected.framing != framing {
766            return Err(BundleError::MalformedStream(format!(
767                "append of ({path:?}, {kind:?}, {framing:?}) does not match the next declared \
768                 member ({:?}, {:?}, {:?})",
769                expected.path, expected.kind, expected.framing
770            )));
771        }
772        validate_member_path(path)
773    }
774
775    /// Append the next member, whose bytes are staged at `src` (a bounded temp). Writes the
776    /// member's `.brmeta` sidecar (size + SHA-256, read from `src`) then the member data,
777    /// both streamed from `src` (never buffered whole). The `path`/`kind` MUST match the next
778    /// member the MANIFEST declared — an out-of-order or unexpected append is refused loud.
779    pub fn append_member(
780        &mut self,
781        path: &str,
782        kind: MemberKind,
783        src: &Path,
784    ) -> Result<(), BundleError> {
785        self.next_expected(path, kind, MemberFraming::Sidecar)?;
786
787        // Pass 1: size (stat) + SHA-256 (one streamed read) → the sidecar, BEFORE the body.
788        let size = std::fs::metadata(src)?.len();
789        let (hashed, sha256) = size_and_sha256(io::BufReader::new(std::fs::File::open(src)?))?;
790        if hashed != size {
791            // The temp changed size between stat and hash — refuse rather than ship a lie.
792            return Err(BundleError::SizeMismatch {
793                path: path.to_string(),
794                expected: size,
795                actual: hashed,
796            });
797        }
798        let meta = MemberMeta { size, sha256 };
799        let meta_json = serde_json::to_vec(&meta)?;
800        append_bytes(
801            &mut self.builder,
802            &StreamManifest::meta_path(path),
803            self.created_at,
804            &meta_json,
805        )?;
806
807        // Pass 2: the member data, streamed from the temp with the size the sidecar committed.
808        let mut f = std::fs::File::open(src)?;
809        let mut header = tar::Header::new_gnu();
810        header.set_size(size);
811        header.set_mode(0o600);
812        header.set_mtime(self.created_at);
813        self.builder.append_data(&mut header, path, &mut f)?;
814        Ok(())
815    }
816
817    /// Append the next member as a run of `chunk_size`-byte `.part{N}` tar entries followed by a
818    /// `.brtrailer` ([`MemberTrailer`]: size + SHA-256). Streams `body` through, hashing + counting
819    /// WHILE writing each chunk — it NEVER stats or buffers the whole member (at most one
820    /// `chunk_size` buffer lives at a time), so a producer that yields a `&mut dyn Read` over an
821    /// engine dump (the libsql `.dump` stream, `pg_dump` stdout) is written in O(chunk) disk +
822    /// memory. The `path`/`kind` MUST match the next declared member AND its framing MUST be
823    /// [`MemberFraming::ChunkedTrailer`] — an out-of-order, unexpected, or wrong-framing append is
824    /// refused loud. `chunk_size` must be non-zero.
825    pub fn append_member_chunked(
826        &mut self,
827        path: &str,
828        kind: MemberKind,
829        body: &mut dyn Read,
830        chunk_size: usize,
831    ) -> Result<(), BundleError> {
832        self.next_expected(path, kind, MemberFraming::ChunkedTrailer)?;
833        let chunk_size = chunk_size.max(1);
834
835        let mut hasher = Sha256::new();
836        let mut total = 0u64;
837        let mut part: u32 = 0;
838        let mut buf = vec![0u8; chunk_size];
839        loop {
840            // Fill a whole chunk buffer (a single `read` may short-read), so every `.part{N}` but
841            // the last is exactly `chunk_size` — a stable, predictable chunking the reader asserts.
842            let mut filled = 0usize;
843            while filled < chunk_size {
844                let n = body.read(&mut buf[filled..])?;
845                if n == 0 {
846                    break;
847                }
848                filled += n;
849            }
850            if filled == 0 {
851                break; // clean EOF on a chunk boundary — no trailing empty part
852            }
853            hasher.update(&buf[..filled]);
854            total += filled as u64;
855            let mut header = tar::Header::new_gnu();
856            header.set_size(filled as u64);
857            header.set_mode(0o600);
858            header.set_mtime(self.created_at);
859            self.builder
860                .append_data(&mut header, chunk_part_path(path, part), &buf[..filled])?;
861            part = part.checked_add(1).ok_or_else(|| {
862                BundleError::MalformedStream(format!("chunked member {path:?} exceeded 2^32 parts"))
863            })?;
864            if filled < chunk_size {
865                break; // short final chunk ⇒ EOF
866            }
867        }
868
869        // The TRAILING trailer: size + SHA-256 of the whole streamed member, AFTER the chunks.
870        let trailer = MemberTrailer {
871            size: total,
872            sha256: hex::encode(hasher.finalize()),
873        };
874        let trailer_json = serde_json::to_vec(&trailer)?;
875        append_bytes(
876            &mut self.builder,
877            &chunk_trailer_path(path),
878            self.created_at,
879            &trailer_json,
880        )?;
881        Ok(())
882    }
883
884    /// Finish the bundle: refuse if any declared member was never appended, then flush.
885    pub fn finish(mut self) -> Result<(), BundleError> {
886        if let Some(missing) = self.remaining.pop_front() {
887            return Err(BundleError::MissingMember(missing.path));
888        }
889        let mut inner = self.builder.into_inner()?;
890        inner.flush()?;
891        Ok(())
892    }
893}
894
895/// Stream a v2 bundle to `out`, producing each member JUST BEFORE it is appended and freeing
896/// its staging temp JUST AFTER — so the transient on-disk staging never exceeds the largest
897/// single member (O(largest member), never O(whole bundle)). For each [`MemberSpec`] in
898/// `specs` (restore order), `stage(spec)` stages THAT member to a temp and returns its path;
899/// the member is appended (sidecar + data) and its temp is then deleted before the next is
900/// staged.
901///
902/// This is the shape the export driver uses from its blocking task: `stage` runs the engine
903/// dump (pg_dump / libsql `VACUUM INTO` / the KV slice / a blob `get`) into a staging temp,
904/// one member at a time.
905pub fn write_bundle_streaming<W, S>(
906    out: W,
907    created_at: u64,
908    data_only: bool,
909    source: SourceInfo,
910    specs: Vec<MemberSpec>,
911    mut stage: S,
912) -> Result<(), BundleError>
913where
914    W: Write,
915    S: FnMut(&MemberSpec) -> Result<PathBuf, BundleError>,
916{
917    let mut writer = BundleStreamWriter::new(out, created_at, data_only, source, specs.clone())?;
918    for spec in &specs {
919        let tmp = stage(spec)?;
920        let appended = writer.append_member(&spec.path, spec.kind, &tmp);
921        // Free the staging temp immediately — this is what bounds the footprint — regardless
922        // of whether the append succeeded.
923        let _ = std::fs::remove_file(&tmp);
924        appended?;
925    }
926    writer.finish()
927}
928
929/// Stream a v3-capable bundle to `out`, dispatching each member by its [`MemberFraming`] — the
930/// chunked-aware superset of [`write_bundle_streaming`] the export driver uses. For a `Sidecar`
931/// member, `stage(spec)` stages it to a bounded temp (returned path), which is appended then
932/// deleted (the inc1 O(largest-member) footprint, unchanged); for a `ChunkedTrailer` member,
933/// `open(spec)` yields a `Box<dyn Read>` over the member's bytes (an engine dump stream) that is
934/// piped STRAIGHT through [`append_member_chunked`](BundleStreamWriter::append_member_chunked) in
935/// `chunk_size` pieces — NO temp, O(chunk) disk + memory. The MANIFEST is v3 iff any member is
936/// chunked (else v2 — see [`BundleStreamWriter::new`]).
937#[allow(clippy::too_many_arguments)] // the bundle head (created_at/data_only/source) + both
938// producers + chunk_size are each load-bearing; grouping them into a struct would only hide them.
939pub fn write_bundle_streaming_v3<W, S, O>(
940    out: W,
941    created_at: u64,
942    data_only: bool,
943    source: SourceInfo,
944    specs: Vec<MemberSpec>,
945    chunk_size: usize,
946    mut stage: S,
947    mut open: O,
948) -> Result<(), BundleError>
949where
950    W: Write,
951    S: FnMut(&MemberSpec) -> Result<PathBuf, BundleError>,
952    O: FnMut(&MemberSpec) -> Result<Box<dyn Read>, BundleError>,
953{
954    let mut writer = BundleStreamWriter::new(out, created_at, data_only, source, specs.clone())?;
955    for spec in &specs {
956        match spec.framing {
957            MemberFraming::Sidecar => {
958                let tmp = stage(spec)?;
959                let appended = writer.append_member(&spec.path, spec.kind, &tmp);
960                let _ = std::fs::remove_file(&tmp);
961                appended?;
962            }
963            MemberFraming::ChunkedTrailer => {
964                let mut body = open(spec)?;
965                writer.append_member_chunked(&spec.path, spec.kind, body.as_mut(), chunk_size)?;
966            }
967        }
968    }
969    writer.finish()
970}
971
972/// A `Read` wrapper that SHA-256-hashes and byte-counts everything read through it, so a
973/// single forward pass over a member yields both its restored bytes and the checksum to
974/// verify — no second pass, no whole-member buffering.
975struct HashingReader<R: Read> {
976    inner: R,
977    hasher: Sha256,
978    count: u64,
979}
980
981impl<R: Read> HashingReader<R> {
982    fn new(inner: R) -> Self {
983        Self {
984            inner,
985            hasher: Sha256::new(),
986            count: 0,
987        }
988    }
989
990    fn finish(self) -> (u64, String) {
991        (self.count, hex::encode(self.hasher.finalize()))
992    }
993}
994
995impl<R: Read> Read for HashingReader<R> {
996    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
997        let n = self.inner.read(buf)?;
998        if n > 0 {
999            self.hasher.update(&buf[..n]);
1000            self.count += n as u64;
1001        }
1002        Ok(n)
1003    }
1004}
1005
1006/// Peek just the `format_version` of a MANIFEST JSON (both v1 and v2 manifests carry it, with
1007/// the same magic) without committing to either struct — the v1/v2 dispatch key.
1008fn peek_format_version(manifest_json: &str) -> Result<u32, BundleError> {
1009    #[derive(Deserialize)]
1010    struct VersionPeek {
1011        #[serde(default)]
1012        magic: String,
1013        format_version: u32,
1014    }
1015    let peek: VersionPeek = serde_json::from_str(manifest_json)?;
1016    if peek.magic != BUNDLE_MAGIC {
1017        return Err(BundleError::BadMagic {
1018            expected: BUNDLE_MAGIC.to_string(),
1019            found: peek.magic,
1020        });
1021    }
1022    Ok(peek.format_version)
1023}
1024
1025/// Stream one member's body through `restore`, hashing + byte-counting everything (draining
1026/// any bytes `restore` leaves unread so the hash + count cover the WHOLE member), then VERIFY
1027/// the streamed size + SHA-256 against what the member declared. A mismatch is the loud
1028/// reject — a corrupt/truncated member never reaches the next restore step.
1029fn verify_member<F>(
1030    body: &mut dyn Read,
1031    spec: &MemberSpec,
1032    expected_size: u64,
1033    expected_sha: &str,
1034    restore: &mut F,
1035) -> Result<(), BundleError>
1036where
1037    F: FnMut(&MemberSpec, u64, &mut dyn Read) -> Result<(), BundleError>,
1038{
1039    let mut counting = HashingReader::new(body);
1040    restore(spec, expected_size, &mut counting)?;
1041    // Drain the remainder so a `restore` that read only part of the member still hashes all.
1042    let mut sink = [0u8; 64 * 1024];
1043    while counting.read(&mut sink)? != 0 {}
1044    let (count, sha) = counting.finish();
1045    if count != expected_size {
1046        return Err(BundleError::SizeMismatch {
1047            path: spec.path.clone(),
1048            expected: expected_size,
1049            actual: count,
1050        });
1051    }
1052    if sha != expected_sha {
1053        return Err(BundleError::ChecksumMismatch {
1054            path: spec.path.clone(),
1055            expected: expected_sha.to_string(),
1056            actual: sha,
1057        });
1058    }
1059    Ok(())
1060}
1061
1062/// VERIFY-THEN-APPLY one member: stream `body` into a bounded temp under `stage_dir` while
1063/// hashing + counting, VERIFY the staged temp's size + SHA-256 against the declared values
1064/// FIRST, and only then hand the verified temp to `restore(spec, &temp)`. The temp is deleted
1065/// after `restore` returns (or on a verification failure) — so a corrupt/truncated/tampered
1066/// member is rejected before any restore applies it (closing the apply-then-verify window of
1067/// the plain streaming reader), while staying O(largest member) on disk.
1068fn stage_and_verify_member<F>(
1069    body: &mut dyn Read,
1070    spec: &MemberSpec,
1071    expected_size: u64,
1072    expected_sha: &str,
1073    stage_dir: &Path,
1074    counter: &mut u64,
1075    restore: &mut F,
1076) -> Result<(), BundleError>
1077where
1078    F: FnMut(&MemberSpec, &Path) -> Result<(), BundleError>,
1079{
1080    let temp = stage_dir.join(format!("brimport-{counter}"));
1081    *counter += 1;
1082    let mut hasher = Sha256::new();
1083    let mut total = 0u64;
1084    {
1085        let mut f = io::BufWriter::new(std::fs::File::create(&temp)?);
1086        let mut buf = [0u8; 64 * 1024];
1087        loop {
1088            let n = body.read(&mut buf)?;
1089            if n == 0 {
1090                break;
1091            }
1092            hasher.update(&buf[..n]);
1093            total += n as u64;
1094            f.write_all(&buf[..n])?;
1095        }
1096        f.flush()?;
1097    }
1098    let sha = hex::encode(hasher.finalize());
1099    if total != expected_size {
1100        let _ = std::fs::remove_file(&temp);
1101        return Err(BundleError::SizeMismatch {
1102            path: spec.path.clone(),
1103            expected: expected_size,
1104            actual: total,
1105        });
1106    }
1107    if sha != expected_sha {
1108        let _ = std::fs::remove_file(&temp);
1109        return Err(BundleError::ChecksumMismatch {
1110            path: spec.path.clone(),
1111            expected: expected_sha.to_string(),
1112            actual: sha,
1113        });
1114    }
1115    // Verified — NOW apply the staged temp, then free it.
1116    let r = restore(spec, &temp);
1117    let _ = std::fs::remove_file(&temp);
1118    r
1119}
1120
1121/// Read a bundle from `src` in a SINGLE FORWARD PASS, handing each member to `restore` as it
1122/// streams — never staging the whole bundle. Reads the MANIFEST first, then for each member
1123/// calls `restore(spec, size, body)`: `body` is a reader over the member's bytes and `size`
1124/// its declared length; the member's SHA-256 is VERIFIED as `body` is consumed (and any bytes
1125/// `restore` leaves unread are drained through the hasher), so a corrupt/truncated member is
1126/// caught before the next.
1127///
1128/// Reads BOTH v2 bundles (ordered `.brmeta` sidecar + data pairs; size/SHA from the sidecar)
1129/// and v1 bundles (size/SHA from the inline MANIFEST) — the compatibility path. For v2 the
1130/// members arrive in the MANIFEST's declared (restore) order, which is enforced; v1 members
1131/// may arrive in any order and are matched by path (preserving v1 semantics). Returns the
1132/// members restored, in the order they were handed to `restore`.
1133pub fn read_bundle_streaming<R, F>(src: R, restore: F) -> Result<Vec<MemberSpec>, BundleError>
1134where
1135    R: Read,
1136    F: FnMut(&MemberSpec, u64, &mut dyn Read) -> Result<(), BundleError>,
1137{
1138    read_bundle_streaming_with_head(src, |_data_only, _source| {}, restore)
1139}
1140
1141/// Like [`read_bundle_streaming`], but also surfaces the bundle HEAD — the `data_only` flag
1142/// and the [`SourceInfo`] — via `on_head`, called ONCE after the MANIFEST is parsed and
1143/// validated, BEFORE any member is handed to `restore`. The import driver needs both ahead
1144/// of the first member (to re-key each member to the TARGET project, and to branch the
1145/// data-only vs full restore), and the v2 MANIFEST (like the v1 one) carries them before any
1146/// member — so a single forward pass can learn them up front. Reads both v2 and v1 bundles,
1147/// exactly as [`read_bundle_streaming`] does.
1148pub fn read_bundle_streaming_with_head<R, H, F>(
1149    src: R,
1150    on_head: H,
1151    mut restore: F,
1152) -> Result<Vec<MemberSpec>, BundleError>
1153where
1154    R: Read,
1155    H: FnMut(bool, &SourceInfo),
1156    F: FnMut(&MemberSpec, u64, &mut dyn Read) -> Result<(), BundleError>,
1157{
1158    read_bundle_core(src, on_head, |spec, size, sha, body| {
1159        verify_member(body, spec, size, sha, &mut restore)
1160    })
1161}
1162
1163/// VERIFY-THEN-APPLY single-pass reader: like [`read_bundle_streaming_with_head`] but each
1164/// member is staged to a bounded temp under `stage_dir` and its size + SHA-256 are VERIFIED
1165/// from that temp BEFORE `restore(spec, &verified_temp)` is called (and the temp is freed
1166/// after). This closes the apply-then-verify window — a corrupt/tampered member is rejected
1167/// before it is applied — while keeping staging bounded to the largest single member. Reads v1
1168/// and v2 bundles. NOTE: this is the inc1 staged reader — it verifies-then-applies but does NOT
1169/// own per-member rollback, so an import aborted mid-stream may have applied EARLIER members.
1170/// inc2's [`read_bundle_streaming_atomic`] adds the atomic shadow-restore (a FULL managed-DB
1171/// member is swapped in only after its trailing SHA verifies, so a bad DB member applies NOTHING
1172/// to live — per-member all-or-nothing); even there, the bundle is not whole-bundle atomic (the
1173/// cross-member window) and a `--data-only` DB member keeps this non-atomic sidecar path.
1174pub fn read_bundle_streaming_staged<R, H, F>(
1175    src: R,
1176    stage_dir: &Path,
1177    on_head: H,
1178    mut restore: F,
1179) -> Result<Vec<MemberSpec>, BundleError>
1180where
1181    R: Read,
1182    H: FnMut(bool, &SourceInfo),
1183    F: FnMut(&MemberSpec, &Path) -> Result<(), BundleError>,
1184{
1185    let mut counter = 0u64;
1186    read_bundle_core(src, on_head, |spec, size, sha, body| {
1187        stage_and_verify_member(body, spec, size, sha, stage_dir, &mut counter, &mut restore)
1188    })
1189}
1190
1191/// The shared single-forward-pass bundle parse (v1 + v2) behind
1192/// [`read_bundle_streaming_with_head`] and [`read_bundle_streaming_staged`]. Peeks the MANIFEST
1193/// version, calls `on_head(data_only, source)` once, then for each member calls
1194/// `per_member(spec, size, sha, body)` — where `size`/`sha` are the member's declared integrity
1195/// values (inline v1 manifest / v2 sidecar) and `body` is the raw member reader. Enforces the
1196/// v2 declared order + sidecar pairing and the v1 manifest completeness, exactly as before.
1197fn read_bundle_core<R, H, M>(
1198    src: R,
1199    mut on_head: H,
1200    mut per_member: M,
1201) -> Result<Vec<MemberSpec>, BundleError>
1202where
1203    R: Read,
1204    H: FnMut(bool, &SourceInfo),
1205    M: FnMut(&MemberSpec, u64, &str, &mut dyn Read) -> Result<(), BundleError>,
1206{
1207    let mut ar = tar::Archive::new(src);
1208    let mut entries = ar.entries()?;
1209
1210    // 1. The MANIFEST (first entry), peeked for its version to pick the v1/v2 path.
1211    let mut first = entries
1212        .next()
1213        .ok_or_else(|| BundleError::MissingManifest(MANIFEST_PATH.to_string()))??;
1214    let fpath = first.path()?.to_string_lossy().into_owned();
1215    if fpath != MANIFEST_PATH {
1216        return Err(BundleError::MissingManifest(MANIFEST_PATH.to_string()));
1217    }
1218    let mut manifest_json = String::new();
1219    first.read_to_string(&mut manifest_json)?;
1220    let version = peek_format_version(&manifest_json)?;
1221    let mut restored: Vec<MemberSpec> = Vec::new();
1222
1223    if version == BUNDLE_FORMAT_VERSION {
1224        // --- v1: inline size/SHA in the manifest; members matched by path (any order). ---
1225        let manifest: Manifest = serde_json::from_str(&manifest_json)?;
1226        manifest.validate()?;
1227        on_head(manifest.data_only, &manifest.source);
1228        let mut seen: HashSet<String> = HashSet::new();
1229        for entry in entries {
1230            let mut entry = entry?;
1231            let path = entry.path()?.to_string_lossy().into_owned();
1232            validate_member_path(&path)?;
1233            let mm = manifest
1234                .members
1235                .iter()
1236                .find(|m| m.path == path)
1237                .ok_or_else(|| BundleError::UnexpectedMember(path.clone()))?;
1238            let spec = MemberSpec::sidecar(mm.path.clone(), mm.kind, mm.pg.clone());
1239            per_member(&spec, mm.size, &mm.sha256, &mut entry)?;
1240            seen.insert(path);
1241            restored.push(spec);
1242        }
1243        for m in &manifest.members {
1244            if !seen.contains(&m.path) {
1245                return Err(BundleError::MissingMember(m.path.clone()));
1246            }
1247        }
1248    } else if version == BUNDLE_FORMAT_VERSION_V2 {
1249        // --- v2: ordered `<path>.brmeta` sidecar then `<path>` data, in MANIFEST order. ---
1250        let manifest: StreamManifest = serde_json::from_str(&manifest_json)?;
1251        manifest.validate()?;
1252        on_head(manifest.data_only, &manifest.source);
1253        let mut specs = manifest.members.iter();
1254        let mut pending_meta: Option<(String, MemberMeta)> = None;
1255        for entry in entries {
1256            let mut entry = entry?;
1257            let path = entry.path()?.to_string_lossy().into_owned();
1258            if let Some(stripped) = path.strip_suffix(META_SUFFIX) {
1259                // A sidecar — stash its meta for the data member that must immediately follow.
1260                if pending_meta.is_some() {
1261                    return Err(BundleError::MalformedStream(format!(
1262                        "sidecar {path:?} arrived with a prior sidecar still unmatched"
1263                    )));
1264                }
1265                let mut buf = String::new();
1266                entry.read_to_string(&mut buf)?;
1267                let meta: MemberMeta = serde_json::from_str(&buf)?;
1268                pending_meta = Some((stripped.to_string(), meta));
1269                continue;
1270            }
1271            validate_member_path(&path)?;
1272            // A data member: its sidecar must have come immediately before, and it must be the
1273            // next member the MANIFEST declared (the enforced forward order).
1274            let (meta_path, meta) = pending_meta.take().ok_or_else(|| {
1275                BundleError::MalformedStream(format!("member {path:?} with no preceding sidecar"))
1276            })?;
1277            if meta_path != path {
1278                return Err(BundleError::MalformedStream(format!(
1279                    "sidecar for {meta_path:?} does not match the following member {path:?}"
1280                )));
1281            }
1282            let spec = specs
1283                .next()
1284                .ok_or_else(|| BundleError::UnexpectedMember(path.clone()))?;
1285            if spec.path != path {
1286                return Err(BundleError::MalformedStream(format!(
1287                    "member {path:?} out of declared order (MANIFEST expected {:?})",
1288                    spec.path
1289                )));
1290            }
1291            per_member(spec, meta.size, &meta.sha256, &mut entry)?;
1292            restored.push(spec.clone());
1293        }
1294        if let Some((mp, _)) = pending_meta.take() {
1295            return Err(BundleError::MissingMember(mp));
1296        }
1297        if let Some(missing) = specs.next() {
1298            return Err(BundleError::MissingMember(missing.path.clone()));
1299        }
1300    } else {
1301        return Err(BundleError::BadVersion {
1302            found: version,
1303            supported: BUNDLE_FORMAT_VERSION_V2,
1304        });
1305    }
1306    Ok(restored)
1307}
1308
1309/// The restore policy the v3-capable single-pass reader [`read_bundle_streaming_atomic`] drives,
1310/// one method per member shape. The format layer owns the PARSE + the per-member integrity VERIFY;
1311/// the implementor (the import driver) owns the APPLY (where a member is re-keyed to the target,
1312/// scope-enforced, and committed) — so a chunked member and a sidecar member of the same kind route
1313/// through the SAME driver re-key/enforce, framing being a transport detail, never a tenancy bypass.
1314///
1315/// A `Sidecar` member arrives fully staged + size/SHA-VERIFIED at a bounded temp
1316/// ([`restore_sidecar`](Self::restore_sidecar)) — verify-THEN-apply, exactly as the v2 staged
1317/// reader. A `ChunkedTrailer` member streams: [`begin_chunked`](Self::begin_chunked) opens the sink
1318/// (a shadow DB / a staging temp / a blob put), [`write_chunk`](Self::write_chunk) feeds each chunk
1319/// as it arrives (the reader hashes + counts), and [`finish_chunked`](Self::finish_chunked) is told
1320/// whether the TRAILING trailer matched — COMMIT the sink only on `verified`, else DROP it, so a
1321/// corrupt/truncated/tampered chunked member applies NOTHING to live (the inc2 atomic shadow).
1322pub trait BundleRestorer {
1323    /// Apply a `Sidecar` member already staged + verified at `temp` (freed by the reader after
1324    /// this returns).
1325    fn restore_sidecar(&mut self, spec: &MemberSpec, temp: &Path) -> Result<(), BundleError>;
1326    /// Open the sink for a `ChunkedTrailer` member, before its first chunk (e.g. create the shadow
1327    /// DB / the staging temp). A failure here aborts the import before any byte is applied.
1328    fn begin_chunked(&mut self, spec: &MemberSpec) -> Result<(), BundleError>;
1329    /// Feed the next slice of a chunked member's bytes to the open sink (called repeatedly, in
1330    /// order, for each chunk the reader streams).
1331    fn write_chunk(&mut self, spec: &MemberSpec, buf: &[u8]) -> Result<(), BundleError>;
1332    /// Finish a chunked member: `verified` is whether the streamed bytes matched the trailer's
1333    /// size + SHA-256. COMMIT-swap the sink on `true`; DROP it (nothing applied to live) on `false`.
1334    fn finish_chunked(&mut self, spec: &MemberSpec, verified: bool) -> Result<(), BundleError>;
1335}
1336
1337/// The **v3-capable atomic single-pass reader** — the inc2 superset of
1338/// [`read_bundle_streaming_staged`]. Reads v1, v2 AND v3 bundles in one forward pass, dispatching
1339/// each member to `restorer` by its [`MemberFraming`]: a `Sidecar` member is staged to a bounded
1340/// temp under `stage_dir` and size/SHA-VERIFIED BEFORE [`restore_sidecar`](BundleRestorer::restore_sidecar)
1341/// (verify-then-apply, unchanged from v2); a `ChunkedTrailer` member streams chunk-by-chunk through
1342/// [`begin_chunked`](BundleRestorer::begin_chunked) / [`write_chunk`](BundleRestorer::write_chunk)
1343/// (the reader hashing + counting, NEVER staging the whole member) and is committed by
1344/// [`finish_chunked`](BundleRestorer::finish_chunked) ONLY when the TRAILING trailer matched — so a
1345/// corrupt/truncated chunked member applies nothing (atomic). `on_head(data_only, source)` fires
1346/// once after the MANIFEST, before any member. Enforces the v2/v3 declared order + per-member
1347/// framing + the v1 completeness, exactly as [`read_bundle_core`].
1348pub fn read_bundle_streaming_atomic<R, H>(
1349    src: R,
1350    stage_dir: &Path,
1351    mut on_head: H,
1352    restorer: &mut dyn BundleRestorer,
1353) -> Result<Vec<MemberSpec>, BundleError>
1354where
1355    R: Read,
1356    H: FnMut(bool, &SourceInfo),
1357{
1358    let mut ar = tar::Archive::new(src);
1359    let mut entries = ar.entries()?;
1360
1361    // 1. The MANIFEST (first entry), peeked for its version.
1362    let mut first = entries
1363        .next()
1364        .ok_or_else(|| BundleError::MissingManifest(MANIFEST_PATH.to_string()))??;
1365    let fpath = first.path()?.to_string_lossy().into_owned();
1366    if fpath != MANIFEST_PATH {
1367        return Err(BundleError::MissingManifest(MANIFEST_PATH.to_string()));
1368    }
1369    let mut manifest_json = String::new();
1370    first.read_to_string(&mut manifest_json)?;
1371    let version = peek_format_version(&manifest_json)?;
1372    let mut counter = 0u64;
1373    let mut restored: Vec<MemberSpec> = Vec::new();
1374
1375    if version == BUNDLE_FORMAT_VERSION {
1376        // --- v1: inline size/SHA in the manifest; members matched by path (any order), all
1377        //     Sidecar-framed (v1 predates chunking). ---
1378        let manifest: Manifest = serde_json::from_str(&manifest_json)?;
1379        manifest.validate()?;
1380        on_head(manifest.data_only, &manifest.source);
1381        let mut seen: HashSet<String> = HashSet::new();
1382        for entry in entries {
1383            let mut entry = entry?;
1384            let path = entry.path()?.to_string_lossy().into_owned();
1385            validate_member_path(&path)?;
1386            let mm = manifest
1387                .members
1388                .iter()
1389                .find(|m| m.path == path)
1390                .ok_or_else(|| BundleError::UnexpectedMember(path.clone()))?;
1391            let spec = MemberSpec::sidecar(mm.path.clone(), mm.kind, mm.pg.clone());
1392            stage_and_verify_member(
1393                &mut entry,
1394                &spec,
1395                mm.size,
1396                &mm.sha256,
1397                stage_dir,
1398                &mut counter,
1399                &mut |s, t| restorer.restore_sidecar(s, t),
1400            )?;
1401            seen.insert(path);
1402            restored.push(spec);
1403        }
1404        for m in &manifest.members {
1405            if !seen.contains(&m.path) {
1406                return Err(BundleError::MissingMember(m.path.clone()));
1407            }
1408        }
1409    } else if version == BUNDLE_FORMAT_VERSION_V2 || version == BUNDLE_FORMAT_VERSION_V3 {
1410        // --- v2/v3: the MANIFEST lists members in restore order; each is consumed per its framing
1411        //     (Sidecar = `.brmeta` + body; ChunkedTrailer = `.part{N}` run + `.brtrailer`). ---
1412        let manifest: StreamManifest = serde_json::from_str(&manifest_json)?;
1413        manifest.validate()?;
1414        on_head(manifest.data_only, &manifest.source);
1415        for spec in manifest.members {
1416            match spec.framing {
1417                MemberFraming::Sidecar => {
1418                    // The sidecar `<path>.brmeta` must come first, then the `<path>` body.
1419                    let mut meta_entry = entries
1420                        .next()
1421                        .ok_or_else(|| BundleError::MissingMember(spec.path.clone()))??;
1422                    let mpath = meta_entry.path()?.to_string_lossy().into_owned();
1423                    let stripped = mpath.strip_suffix(META_SUFFIX).ok_or_else(|| {
1424                        BundleError::MalformedStream(format!(
1425                            "expected sidecar {:?} for member {:?}, found {mpath:?}",
1426                            StreamManifest::meta_path(&spec.path),
1427                            spec.path
1428                        ))
1429                    })?;
1430                    if stripped != spec.path {
1431                        return Err(BundleError::MalformedStream(format!(
1432                            "sidecar {mpath:?} does not match the declared member {:?}",
1433                            spec.path
1434                        )));
1435                    }
1436                    let mut mbuf = String::new();
1437                    meta_entry.read_to_string(&mut mbuf)?;
1438                    let meta: MemberMeta = serde_json::from_str(&mbuf)?;
1439
1440                    let mut body_entry = entries
1441                        .next()
1442                        .ok_or_else(|| BundleError::MissingMember(spec.path.clone()))??;
1443                    let bpath = body_entry.path()?.to_string_lossy().into_owned();
1444                    validate_member_path(&bpath)?;
1445                    if bpath != spec.path {
1446                        return Err(BundleError::MalformedStream(format!(
1447                            "member {bpath:?} out of declared order (MANIFEST expected {:?})",
1448                            spec.path
1449                        )));
1450                    }
1451                    stage_and_verify_member(
1452                        &mut body_entry,
1453                        &spec,
1454                        meta.size,
1455                        &meta.sha256,
1456                        stage_dir,
1457                        &mut counter,
1458                        &mut |s, t| restorer.restore_sidecar(s, t),
1459                    )?;
1460                    restored.push(spec);
1461                }
1462                MemberFraming::ChunkedTrailer => {
1463                    read_chunked_member(&mut entries, &spec, restorer)?;
1464                    restored.push(spec);
1465                }
1466            }
1467        }
1468        // No tar entry may remain past the last declared member.
1469        if let Some(extra) = entries.next() {
1470            let extra = extra?;
1471            let path = extra.path()?.to_string_lossy().into_owned();
1472            return Err(BundleError::UnexpectedMember(path));
1473        }
1474    } else {
1475        return Err(BundleError::BadVersion {
1476            found: version,
1477            supported: BUNDLE_FORMAT_VERSION_V3,
1478        });
1479    }
1480    Ok(restored)
1481}
1482
1483/// Consume one `ChunkedTrailer` member from `entries`: `begin_chunked`, stream each
1484/// `<path>.part{N:05}` (monotonic from `00000`) through `write_chunk` while hashing + counting,
1485/// then VERIFY the streamed size + SHA-256 against the `<path>.brtrailer` and `finish_chunked`
1486/// (commit on match, drop on mismatch). Any parse/order/IO failure drops the sink (abort) and
1487/// propagates, so a corrupt/truncated chunked member applies nothing to live.
1488fn read_chunked_member<R: Read>(
1489    entries: &mut tar::Entries<'_, R>,
1490    spec: &MemberSpec,
1491    restorer: &mut dyn BundleRestorer,
1492) -> Result<(), BundleError> {
1493    restorer.begin_chunked(spec)?;
1494    let mut hasher = Sha256::new();
1495    let mut total: u64 = 0;
1496    let mut next_part: u32 = 0;
1497    // The trailer we expect to find after the last part; `None` until seen.
1498    let mut outcome: Result<MemberTrailer, BundleError> =
1499        Err(BundleError::MalformedStream(format!(
1500            "chunked member {:?} ended before its {TRAILER_SUFFIX} trailer",
1501            spec.path
1502        )));
1503    loop {
1504        let entry = match entries.next() {
1505            Some(Ok(e)) => e,
1506            Some(Err(e)) => {
1507                outcome = Err(BundleError::Io(e));
1508                break;
1509            }
1510            None => break, // outcome stays "ended before trailer"
1511        };
1512        let mut entry = entry;
1513        let epath = match entry.path() {
1514            Ok(p) => p.to_string_lossy().into_owned(),
1515            Err(e) => {
1516                outcome = Err(BundleError::Io(e));
1517                break;
1518            }
1519        };
1520        if let Some(stripped) = epath.strip_suffix(TRAILER_SUFFIX) {
1521            if stripped != spec.path {
1522                outcome = Err(BundleError::MalformedStream(format!(
1523                    "trailer {epath:?} does not match the chunked member {:?}",
1524                    spec.path
1525                )));
1526                break;
1527            }
1528            let mut tbuf = String::new();
1529            if let Err(e) = entry.read_to_string(&mut tbuf) {
1530                outcome = Err(BundleError::Io(e));
1531                break;
1532            }
1533            outcome = serde_json::from_str::<MemberTrailer>(&tbuf).map_err(BundleError::Json);
1534            break;
1535        }
1536        // Otherwise it must be the NEXT expected `.part{N}` (monotonic, matching the member path).
1537        let expected = chunk_part_path(&spec.path, next_part);
1538        if epath != expected {
1539            outcome = Err(BundleError::MalformedStream(format!(
1540                "chunked member {:?}: expected chunk {expected:?}, found {epath:?} \
1541                 (missing/out-of-order part or wrong member)",
1542                spec.path
1543            )));
1544            break;
1545        }
1546        // Stream this part's bytes to the sink, hashing + counting the whole member.
1547        let mut rbuf = [0u8; 64 * 1024];
1548        let part_res: Result<(), BundleError> = loop {
1549            match entry.read(&mut rbuf) {
1550                Ok(0) => break Ok(()),
1551                Ok(n) => {
1552                    hasher.update(&rbuf[..n]);
1553                    total += n as u64;
1554                    if let Err(e) = restorer.write_chunk(spec, &rbuf[..n]) {
1555                        break Err(e);
1556                    }
1557                }
1558                Err(e) => break Err(BundleError::Io(e)),
1559            }
1560        };
1561        if let Err(e) = part_res {
1562            outcome = Err(e);
1563            break;
1564        }
1565        next_part = match next_part.checked_add(1) {
1566            Some(v) => v,
1567            None => {
1568                outcome = Err(BundleError::MalformedStream(format!(
1569                    "chunked member {:?} exceeded 2^32 parts",
1570                    spec.path
1571                )));
1572                break;
1573            }
1574        };
1575    }
1576    match outcome {
1577        Ok(trailer) => {
1578            let actual = hex::encode(hasher.finalize());
1579            let verified = total == trailer.size && actual == trailer.sha256;
1580            // Tell the sink whether to commit or drop BEFORE raising a loud mismatch, so the
1581            // shadow is torn down on a bad member even as the error propagates.
1582            restorer.finish_chunked(spec, verified)?;
1583            if total != trailer.size {
1584                return Err(BundleError::SizeMismatch {
1585                    path: spec.path.clone(),
1586                    expected: trailer.size,
1587                    actual: total,
1588                });
1589            }
1590            if actual != trailer.sha256 {
1591                return Err(BundleError::ChecksumMismatch {
1592                    path: spec.path.clone(),
1593                    expected: trailer.sha256,
1594                    actual,
1595                });
1596            }
1597            Ok(())
1598        }
1599        Err(e) => {
1600            // Abort the sink (drop the shadow) on any streaming/parse failure, then surface it.
1601            let _ = restorer.finish_chunked(spec, false);
1602            Err(e)
1603        }
1604    }
1605}
1606
1607#[cfg(test)]
1608mod tests {
1609    use super::*;
1610
1611    fn write_staging(dir: &Path, name: &str, bytes: &[u8]) -> PathBuf {
1612        let p = dir.join(name);
1613        std::fs::write(&p, bytes).unwrap();
1614        p
1615    }
1616
1617    #[test]
1618    fn round_trips_a_bundle_and_verifies_members() {
1619        let td = tempfile::tempdir().unwrap();
1620        let stage = td.path().join("stage");
1621        std::fs::create_dir_all(&stage).unwrap();
1622        let kv = write_staging(&stage, "kv.bin", b"BRKVDUMP\x01\x02\x03");
1623        let pg = write_staging(&stage, "app.pgc", &vec![7u8; 5000]);
1624
1625        let bundle = td.path().join("out.brproj");
1626        let staged = vec![
1627            StagedMember {
1628                path: KV_MEMBER_PATH.to_string(),
1629                kind: MemberKind::Kv,
1630                source: kv,
1631                pg: None,
1632            },
1633            StagedMember {
1634                path: "sql/app.pgc".to_string(),
1635                kind: MemberKind::PgDump,
1636                source: pg,
1637                pg: Some(PgMemberInfo {
1638                    server_version_num: "160004".to_string(),
1639                    client_image: "pgvector/pgvector:pg16".to_string(),
1640                    schema_fingerprint: Some("deadbeef".to_string()),
1641                }),
1642            },
1643        ];
1644        let written = write_bundle(
1645            &bundle,
1646            1_700_000_000,
1647            true,
1648            SourceInfo {
1649                project: "acme".to_string(),
1650                site: None,
1651            },
1652            &staged,
1653        )
1654        .unwrap();
1655        assert_eq!(written.magic, BUNDLE_MAGIC);
1656        assert!(written.data_only);
1657        assert_eq!(written.pg_members().count(), 1);
1658
1659        // Re-read the manifest from disk.
1660        let m = read_manifest(&bundle).unwrap();
1661        assert_eq!(m, written);
1662        assert_eq!(
1663            m.pg_members()
1664                .next()
1665                .unwrap()
1666                .pg
1667                .as_ref()
1668                .unwrap()
1669                .client_image,
1670            "pgvector/pgvector:pg16"
1671        );
1672
1673        // Extract + verify.
1674        let out = td.path().join("extract");
1675        let m2 = extract_and_verify(&bundle, &out).unwrap();
1676        assert_eq!(m2, written);
1677        assert_eq!(
1678            std::fs::read(out.join("sql/app.pgc")).unwrap(),
1679            vec![7u8; 5000]
1680        );
1681        assert_eq!(
1682            std::fs::read(out.join(KV_MEMBER_PATH)).unwrap(),
1683            b"BRKVDUMP\x01\x02\x03"
1684        );
1685    }
1686
1687    #[test]
1688    fn rejects_a_foreign_tar_and_a_future_version() {
1689        // Bad magic: hand-build a tar whose MANIFEST carries the wrong magic.
1690        let td = tempfile::tempdir().unwrap();
1691        let bad = td.path().join("bad.tar");
1692        {
1693            let mut b = tar::Builder::new(std::fs::File::create(&bad).unwrap());
1694            let m = Manifest {
1695                magic: "NOTBRPROJ".to_string(),
1696                format_version: BUNDLE_FORMAT_VERSION,
1697                created_at: 0,
1698                data_only: true,
1699                source: SourceInfo {
1700                    project: "x".to_string(),
1701                    site: None,
1702                },
1703                members: vec![],
1704            };
1705            let j = serde_json::to_vec(&m).unwrap();
1706            append_bytes(&mut b, MANIFEST_PATH, 0, &j).unwrap();
1707            b.finish().unwrap();
1708        }
1709        assert!(matches!(
1710            read_manifest(&bad),
1711            Err(BundleError::BadMagic { .. })
1712        ));
1713
1714        // Bad version.
1715        let futv = td.path().join("futv.tar");
1716        {
1717            let mut b = tar::Builder::new(std::fs::File::create(&futv).unwrap());
1718            let m = Manifest {
1719                magic: BUNDLE_MAGIC.to_string(),
1720                format_version: BUNDLE_FORMAT_VERSION + 99,
1721                created_at: 0,
1722                data_only: true,
1723                source: SourceInfo {
1724                    project: "x".to_string(),
1725                    site: None,
1726                },
1727                members: vec![],
1728            };
1729            let j = serde_json::to_vec(&m).unwrap();
1730            append_bytes(&mut b, MANIFEST_PATH, 0, &j).unwrap();
1731            b.finish().unwrap();
1732        }
1733        assert!(matches!(
1734            read_manifest(&futv),
1735            Err(BundleError::BadVersion { .. })
1736        ));
1737    }
1738
1739    #[test]
1740    fn extract_catches_a_checksum_mismatch() {
1741        // Hand-build a bundle whose MANIFEST lies about a member's checksum; extraction
1742        // must catch the byte-level mismatch (the verify discipline, load-bearing).
1743        let td = tempfile::tempdir().unwrap();
1744        let bad = td.path().join("tamper.tar");
1745        {
1746            let mut b = tar::Builder::new(std::fs::File::create(&bad).unwrap());
1747            let m = Manifest {
1748                magic: BUNDLE_MAGIC.to_string(),
1749                format_version: BUNDLE_FORMAT_VERSION,
1750                created_at: 0,
1751                data_only: true,
1752                source: SourceInfo {
1753                    project: "x".to_string(),
1754                    site: None,
1755                },
1756                members: vec![ManifestMember {
1757                    path: "sql/app.pgc".to_string(),
1758                    kind: MemberKind::PgDump,
1759                    size: 3,
1760                    sha256: "00".repeat(32), // deliberately wrong
1761                    pg: None,
1762                    sql_cursor: None,
1763                }],
1764            };
1765            let j = serde_json::to_vec(&m).unwrap();
1766            append_bytes(&mut b, MANIFEST_PATH, 0, &j).unwrap();
1767            append_bytes(&mut b, "sql/app.pgc", 0, b"abc").unwrap();
1768            b.finish().unwrap();
1769        }
1770        let out = td.path().join("x");
1771        assert!(matches!(
1772            extract_and_verify(&bad, &out),
1773            Err(BundleError::ChecksumMismatch { .. })
1774        ));
1775    }
1776
1777    #[test]
1778    fn rejects_unsafe_member_paths() {
1779        assert!(validate_member_path("sql/app.pgc").is_ok());
1780        assert!(validate_member_path("libsql/acme/site/db.sqlite").is_ok());
1781        assert!(validate_member_path("/etc/passwd").is_err());
1782        assert!(validate_member_path("../escape").is_err());
1783        assert!(validate_member_path("a/../b").is_err());
1784        assert!(validate_member_path("").is_err());
1785    }
1786
1787    // ---- format v2 (streamable) ------------------------------------------------------------
1788
1789    #[test]
1790    fn v2_round_trips_streaming_write_then_single_pass_read() {
1791        let td = tempfile::tempdir().unwrap();
1792        let stage_dir = td.path().join("stage");
1793        std::fs::create_dir_all(&stage_dir).unwrap();
1794
1795        // Members in RESTORE order (identity → blobs → pg → kv), with distinct sizes.
1796        let members: Vec<(MemberSpec, Vec<u8>)> = vec![
1797            (
1798                MemberSpec {
1799                    path: IDENTITY_MEMBER_PATH.to_string(),
1800                    kind: MemberKind::Identity,
1801                    framing: MemberFraming::Sidecar,
1802                    pg: None,
1803                    sql_cursor: None,
1804                },
1805                b"IDENTITY-KEK-TRANSPORT".to_vec(),
1806            ),
1807            (
1808                MemberSpec {
1809                    path: "blobs/acme/obj1".to_string(),
1810                    kind: MemberKind::Blob,
1811                    framing: MemberFraming::Sidecar,
1812                    pg: None,
1813                    sql_cursor: None,
1814                },
1815                vec![0xAB; 4096],
1816            ),
1817            (
1818                MemberSpec {
1819                    path: "sql/app.pgc".to_string(),
1820                    kind: MemberKind::PgDump,
1821                    framing: MemberFraming::Sidecar,
1822                    pg: Some(PgMemberInfo {
1823                        server_version_num: "160004".to_string(),
1824                        client_image: "pgvector/pgvector:pg16".to_string(),
1825                        schema_fingerprint: Some("deadbeef".to_string()),
1826                    }),
1827                    sql_cursor: None,
1828                },
1829                vec![7u8; 50_000],
1830            ),
1831            (
1832                MemberSpec {
1833                    path: KV_MEMBER_PATH.to_string(),
1834                    kind: MemberKind::Kv,
1835                    framing: MemberFraming::Sidecar,
1836                    pg: None,
1837                    sql_cursor: None,
1838                },
1839                b"BRKVDUMP\x01\x02\x03".to_vec(),
1840            ),
1841        ];
1842        let specs: Vec<MemberSpec> = members.iter().map(|(s, _)| s.clone()).collect();
1843        let bytes_by_path: std::collections::HashMap<String, Vec<u8>> = members
1844            .iter()
1845            .map(|(s, b)| (s.path.clone(), b.clone()))
1846            .collect();
1847
1848        // Stream the bundle into an in-memory sink (models the network response — NOT node
1849        // disk), staging one member at a time.
1850        let mut bundle: Vec<u8> = Vec::new();
1851        let mut idx = 0u32;
1852        write_bundle_streaming(
1853            &mut bundle,
1854            1_700_000_000,
1855            false,
1856            SourceInfo {
1857                project: "acme".to_string(),
1858                site: Some("prod".to_string()),
1859            },
1860            specs.clone(),
1861            |spec| {
1862                let data = bytes_by_path.get(&spec.path).expect("a known member");
1863                let tmp = stage_dir.join(format!("m{idx}"));
1864                idx += 1;
1865                std::fs::write(&tmp, data).unwrap();
1866                Ok(tmp)
1867            },
1868        )
1869        .unwrap();
1870
1871        // Single forward-pass read: collect each member's restored bytes.
1872        let mut got: Vec<(MemberSpec, Vec<u8>)> = Vec::new();
1873        let restored = read_bundle_streaming(bundle.as_slice(), |spec, size, body| {
1874            let mut v = Vec::new();
1875            body.read_to_end(&mut v).map_err(BundleError::Io)?;
1876            assert_eq!(v.len() as u64, size, "declared size == streamed bytes");
1877            got.push((spec.clone(), v));
1878            Ok(())
1879        })
1880        .unwrap();
1881
1882        assert_eq!(restored, specs, "members restored in the declared order");
1883        assert_eq!(got.len(), members.len());
1884        for ((ws, wb), (gs, gb)) in members.iter().zip(&got) {
1885            assert_eq!(ws, gs);
1886            assert_eq!(wb, gb, "member {:?} bytes round-trip", ws.path);
1887        }
1888        // The pg provenance survives the v2 round-trip.
1889        let pg = restored
1890            .iter()
1891            .find(|m| m.kind == MemberKind::PgDump)
1892            .unwrap();
1893        assert_eq!(
1894            pg.pg.as_ref().unwrap().client_image,
1895            "pgvector/pgvector:pg16"
1896        );
1897    }
1898
1899    #[test]
1900    fn read_bundle_streaming_reads_a_v1_bundle() {
1901        // The v2-aware single-pass reader reads a bundle written in the legacy v1 format —
1902        // the stated read-compatibility contract.
1903        let td = tempfile::tempdir().unwrap();
1904        let stage = td.path().join("stage");
1905        std::fs::create_dir_all(&stage).unwrap();
1906        let kv = write_staging(&stage, "kv.bin", b"BRKVDUMP\x01\x02\x03");
1907        let pg = write_staging(&stage, "app.pgc", &vec![7u8; 5000]);
1908        let bundle = td.path().join("v1.brproj");
1909        let staged = vec![
1910            StagedMember {
1911                path: KV_MEMBER_PATH.to_string(),
1912                kind: MemberKind::Kv,
1913                source: kv,
1914                pg: None,
1915            },
1916            StagedMember {
1917                path: "sql/app.pgc".to_string(),
1918                kind: MemberKind::PgDump,
1919                source: pg,
1920                pg: Some(PgMemberInfo {
1921                    server_version_num: "160004".to_string(),
1922                    client_image: "pgvector/pgvector:pg16".to_string(),
1923                    schema_fingerprint: None,
1924                }),
1925            },
1926        ];
1927        write_bundle(
1928            &bundle,
1929            1_700_000_000,
1930            true,
1931            SourceInfo {
1932                project: "acme".to_string(),
1933                site: None,
1934            },
1935            &staged,
1936        )
1937        .unwrap();
1938
1939        let file = std::fs::File::open(&bundle).unwrap();
1940        let mut got: std::collections::HashMap<String, Vec<u8>> = std::collections::HashMap::new();
1941        let restored = read_bundle_streaming(io::BufReader::new(file), |spec, _size, body| {
1942            let mut v = Vec::new();
1943            body.read_to_end(&mut v).map_err(BundleError::Io)?;
1944            got.insert(spec.path.clone(), v);
1945            Ok(())
1946        })
1947        .unwrap();
1948        assert_eq!(restored.len(), 2);
1949        assert_eq!(got.get(KV_MEMBER_PATH).unwrap(), b"BRKVDUMP\x01\x02\x03");
1950        assert_eq!(got.get("sql/app.pgc").unwrap(), &vec![7u8; 5000]);
1951    }
1952
1953    #[test]
1954    fn read_bundle_streaming_catches_a_tampered_member() {
1955        // Hand-build a v2 bundle whose sidecar lies about the member's SHA — the single-pass
1956        // reader must reject it (the verify discipline, load-bearing).
1957        let mut buf: Vec<u8> = Vec::new();
1958        {
1959            let mut b = tar::Builder::new(&mut buf);
1960            let manifest = StreamManifest {
1961                magic: BUNDLE_MAGIC.to_string(),
1962                format_version: BUNDLE_FORMAT_VERSION_V2,
1963                created_at: 0,
1964                data_only: true,
1965                source: SourceInfo {
1966                    project: "x".to_string(),
1967                    site: None,
1968                },
1969                members: vec![MemberSpec {
1970                    path: "sql/app.pgc".to_string(),
1971                    kind: MemberKind::PgDump,
1972                    framing: MemberFraming::Sidecar,
1973                    pg: None,
1974                    sql_cursor: None,
1975                }],
1976                delta: None,
1977            };
1978            append_bytes(
1979                &mut b,
1980                MANIFEST_PATH,
1981                0,
1982                &serde_json::to_vec(&manifest).unwrap(),
1983            )
1984            .unwrap();
1985            let meta = MemberMeta {
1986                size: 3,
1987                sha256: "00".repeat(32), // deliberately wrong
1988            };
1989            append_bytes(
1990                &mut b,
1991                "sql/app.pgc.brmeta",
1992                0,
1993                &serde_json::to_vec(&meta).unwrap(),
1994            )
1995            .unwrap();
1996            append_bytes(&mut b, "sql/app.pgc", 0, b"abc").unwrap();
1997            b.finish().unwrap();
1998        }
1999        let r = read_bundle_streaming(buf.as_slice(), |_s, _sz, body| {
2000            let mut v = Vec::new();
2001            body.read_to_end(&mut v).map_err(BundleError::Io)?;
2002            Ok(())
2003        });
2004        assert!(
2005            matches!(r, Err(BundleError::ChecksumMismatch { .. })),
2006            "got {r:?}"
2007        );
2008    }
2009
2010    #[test]
2011    fn read_bundle_streaming_rejects_a_member_without_its_sidecar() {
2012        let mut buf: Vec<u8> = Vec::new();
2013        {
2014            let mut b = tar::Builder::new(&mut buf);
2015            let manifest = StreamManifest {
2016                magic: BUNDLE_MAGIC.to_string(),
2017                format_version: BUNDLE_FORMAT_VERSION_V2,
2018                created_at: 0,
2019                data_only: true,
2020                source: SourceInfo {
2021                    project: "x".to_string(),
2022                    site: None,
2023                },
2024                members: vec![MemberSpec {
2025                    path: "sql/app.pgc".to_string(),
2026                    kind: MemberKind::PgDump,
2027                    framing: MemberFraming::Sidecar,
2028                    pg: None,
2029                    sql_cursor: None,
2030                }],
2031                delta: None,
2032            };
2033            append_bytes(
2034                &mut b,
2035                MANIFEST_PATH,
2036                0,
2037                &serde_json::to_vec(&manifest).unwrap(),
2038            )
2039            .unwrap();
2040            // No sidecar — straight to the data member.
2041            append_bytes(&mut b, "sql/app.pgc", 0, b"abc").unwrap();
2042            b.finish().unwrap();
2043        }
2044        let r = read_bundle_streaming(buf.as_slice(), |_s, _sz, _b| Ok(()));
2045        assert!(
2046            matches!(r, Err(BundleError::MalformedStream(_))),
2047            "got {r:?}"
2048        );
2049    }
2050
2051    #[test]
2052    fn write_bundle_streaming_refuses_an_out_of_order_or_missing_member() {
2053        // append_member must match the declared order; finish must catch a shortfall.
2054        let specs = vec![
2055            MemberSpec {
2056                path: "a".to_string(),
2057                kind: MemberKind::Blob,
2058                framing: MemberFraming::Sidecar,
2059                pg: None,
2060                sql_cursor: None,
2061            },
2062            MemberSpec {
2063                path: "b".to_string(),
2064                kind: MemberKind::Blob,
2065                framing: MemberFraming::Sidecar,
2066                pg: None,
2067                sql_cursor: None,
2068            },
2069        ];
2070        let td = tempfile::tempdir().unwrap();
2071        let tmp = td.path().join("x");
2072        std::fs::write(&tmp, b"hello").unwrap();
2073
2074        // Out-of-order append ("b" when "a" is next) is refused.
2075        let mut sink: Vec<u8> = Vec::new();
2076        let mut w = BundleStreamWriter::new(
2077            &mut sink,
2078            0,
2079            true,
2080            SourceInfo {
2081                project: "x".to_string(),
2082                site: None,
2083            },
2084            specs.clone(),
2085        )
2086        .unwrap();
2087        assert!(matches!(
2088            w.append_member("b", MemberKind::Blob, &tmp),
2089            Err(BundleError::MalformedStream(_))
2090        ));
2091
2092        // A shortfall (declared 2, appended 1) is caught at finish.
2093        let mut sink2: Vec<u8> = Vec::new();
2094        let mut w2 = BundleStreamWriter::new(
2095            &mut sink2,
2096            0,
2097            true,
2098            SourceInfo {
2099                project: "x".to_string(),
2100                site: None,
2101            },
2102            specs,
2103        )
2104        .unwrap();
2105        w2.append_member("a", MemberKind::Blob, &tmp).unwrap();
2106        assert!(matches!(w2.finish(), Err(BundleError::MissingMember(_))));
2107    }
2108
2109    /// Total bytes currently staged on disk under `dir` (non-recursive; the test stages flat).
2110    fn staged_bytes(dir: &Path) -> u64 {
2111        std::fs::read_dir(dir)
2112            .unwrap()
2113            .filter_map(|e| e.ok())
2114            .filter_map(|e| e.metadata().ok())
2115            .filter(|m| m.is_file())
2116            .map(|m| m.len())
2117            .sum()
2118    }
2119
2120    #[test]
2121    fn write_bundle_streaming_bounds_staging_to_the_largest_member() {
2122        // THE measured bounded-staging proof: 6 members of 256 KiB each — largest member =
2123        // 256 KiB, whole bundle = 1.5 MiB. We measure the ACTUAL on-disk staging during the
2124        // real streaming write and assert its PEAK stays ≈ one member, never the whole bundle.
2125        let td = tempfile::tempdir().unwrap();
2126        let stage_dir = td.path().join("stage");
2127        std::fs::create_dir_all(&stage_dir).unwrap();
2128
2129        const MEMBER: usize = 256 * 1024;
2130        const N: usize = 6;
2131        let largest = MEMBER as u64;
2132        let whole = (MEMBER * N) as u64;
2133
2134        let specs: Vec<MemberSpec> = (0..N)
2135            .map(|i| MemberSpec {
2136                path: format!("blobs/acme/obj{i}"),
2137                kind: MemberKind::Blob,
2138                framing: MemberFraming::Sidecar,
2139                pg: None,
2140                sql_cursor: None,
2141            })
2142            .collect();
2143
2144        // Real streaming writer: produce-one → append → delete.
2145        let mut peak: u64 = 0;
2146        let mut bundle: Vec<u8> = Vec::new();
2147        write_bundle_streaming(
2148            &mut bundle,
2149            0,
2150            false,
2151            SourceInfo {
2152                project: "acme".to_string(),
2153                site: None,
2154            },
2155            specs.clone(),
2156            |spec| {
2157                let tmp = stage_dir.join(spec.path.rsplit('/').next().unwrap());
2158                std::fs::write(&tmp, vec![0u8; MEMBER]).unwrap();
2159                peak = peak.max(staged_bytes(&stage_dir)); // measured AFTER staging this member
2160                Ok(tmp)
2161            },
2162        )
2163        .unwrap();
2164
2165        // The transient staging never held more than ~one member — NEVER the whole bundle.
2166        // (A filesystem slack allowance of one 4 KiB block keeps this robust.)
2167        assert!(
2168            peak <= largest + 4096,
2169            "streaming staging peak {peak} must stay ≈ one member ({largest}), \
2170             far below the whole bundle ({whole})"
2171        );
2172        assert!(
2173            peak < whole / 2,
2174            "peak {peak} must be far below whole-bundle staging {whole}"
2175        );
2176
2177        // Mutation control: the metric is SENSITIVE. A writer that staged every member up
2178        // front (the whole-bundle-staging regression this proof guards against) drives the
2179        // SAME metric to `whole` — so the assertions above would go RED under that mutation.
2180        for f in std::fs::read_dir(&stage_dir).unwrap() {
2181            let _ = std::fs::remove_file(f.unwrap().path());
2182        }
2183        let mut mutant_peak: u64 = 0;
2184        for i in 0..N {
2185            let tmp = stage_dir.join(format!("all{i}"));
2186            std::fs::write(&tmp, vec![0u8; MEMBER]).unwrap();
2187            mutant_peak = mutant_peak.max(staged_bytes(&stage_dir));
2188        }
2189        assert_eq!(
2190            mutant_peak, whole,
2191            "whole-bundle-staging mutation drives the metric to the full bundle"
2192        );
2193        assert!(
2194            mutant_peak > largest + 4096,
2195            "so the bounded-staging assertion above is genuinely RED under that mutation"
2196        );
2197    }
2198
2199    #[test]
2200    fn read_bundle_streaming_staged_verifies_before_apply() {
2201        use std::sync::atomic::{AtomicUsize, Ordering};
2202
2203        // A tampered v2 bundle: the sidecar lies about the member's SHA. The STAGED reader must
2204        // reject it, and crucially must NOT call `restore` (apply) for the bad member — the
2205        // verify-THEN-apply guarantee (MEDIUM-1(b)).
2206        let mut buf: Vec<u8> = Vec::new();
2207        {
2208            let mut b = tar::Builder::new(&mut buf);
2209            let manifest = StreamManifest {
2210                magic: BUNDLE_MAGIC.to_string(),
2211                format_version: BUNDLE_FORMAT_VERSION_V2,
2212                created_at: 0,
2213                data_only: true,
2214                source: SourceInfo {
2215                    project: "x".to_string(),
2216                    site: None,
2217                },
2218                members: vec![MemberSpec {
2219                    path: "sql/app.pgc".to_string(),
2220                    kind: MemberKind::PgDump,
2221                    framing: MemberFraming::Sidecar,
2222                    pg: None,
2223                    sql_cursor: None,
2224                }],
2225                delta: None,
2226            };
2227            append_bytes(
2228                &mut b,
2229                MANIFEST_PATH,
2230                0,
2231                &serde_json::to_vec(&manifest).unwrap(),
2232            )
2233            .unwrap();
2234            let meta = MemberMeta {
2235                size: 3,
2236                sha256: "00".repeat(32), // deliberately wrong
2237            };
2238            append_bytes(
2239                &mut b,
2240                "sql/app.pgc.brmeta",
2241                0,
2242                &serde_json::to_vec(&meta).unwrap(),
2243            )
2244            .unwrap();
2245            append_bytes(&mut b, "sql/app.pgc", 0, b"abc").unwrap();
2246            b.finish().unwrap();
2247        }
2248        let td = tempfile::tempdir().unwrap();
2249        let applied = AtomicUsize::new(0);
2250        let r = read_bundle_streaming_staged(
2251            buf.as_slice(),
2252            td.path(),
2253            |_d, _s| {},
2254            |_spec, _temp| {
2255                applied.fetch_add(1, Ordering::SeqCst);
2256                Ok(())
2257            },
2258        );
2259        assert!(
2260            matches!(r, Err(BundleError::ChecksumMismatch { .. })),
2261            "got {r:?}"
2262        );
2263        assert_eq!(
2264            applied.load(Ordering::SeqCst),
2265            0,
2266            "restore (apply) must NOT run for a member that fails verification"
2267        );
2268
2269        // A clean v2 bundle: `restore` runs ONCE with the verified temp carrying the exact bytes.
2270        let stage = td.path().join("gen");
2271        std::fs::create_dir_all(&stage).unwrap();
2272        let mut good: Vec<u8> = Vec::new();
2273        write_bundle_streaming(
2274            &mut good,
2275            0,
2276            true,
2277            SourceInfo {
2278                project: "x".to_string(),
2279                site: None,
2280            },
2281            vec![MemberSpec {
2282                path: KV_MEMBER_PATH.to_string(),
2283                kind: MemberKind::Kv,
2284                framing: MemberFraming::Sidecar,
2285                pg: None,
2286                sql_cursor: None,
2287            }],
2288            |_s| {
2289                let p = stage.join("m");
2290                std::fs::write(&p, b"BRKVDUMP-OK").unwrap();
2291                Ok(p)
2292            },
2293        )
2294        .unwrap();
2295        let seen = std::cell::RefCell::new(Vec::new());
2296        read_bundle_streaming_staged(
2297            good.as_slice(),
2298            td.path(),
2299            |_d, _s| {},
2300            |spec, temp| {
2301                seen.borrow_mut()
2302                    .push((spec.path.clone(), std::fs::read(temp).unwrap()));
2303                Ok(())
2304            },
2305        )
2306        .unwrap();
2307        assert_eq!(
2308            seen.into_inner(),
2309            vec![("kv.brkvdump".to_string(), b"BRKVDUMP-OK".to_vec())],
2310            "a verified member is applied from its staged temp with the exact bytes"
2311        );
2312    }
2313
2314    // ---- format v3 (chunked trailing-trailer members) -------------------------------------
2315
2316    /// A `BundleRestorer` that records exactly what the reader drove: sidecar applies, and each
2317    /// chunked member's begin/write/finish — so a test can assert the commit-vs-drop decision and
2318    /// the reassembled bytes, and that a chunked body is delivered chunk-by-chunk (never staged
2319    /// whole by the format layer).
2320    #[derive(Default)]
2321    struct RecordingRestorer {
2322        sidecars: Vec<(MemberSpec, Vec<u8>)>,
2323        begins: Vec<String>,
2324        finishes: Vec<(String, bool)>,
2325        /// The in-flight chunked member's accumulated bytes (between begin and finish).
2326        cur: Option<Vec<u8>>,
2327        /// Committed chunked members: (spec, reassembled bytes) — only the `verified` ones.
2328        committed_chunked: Vec<(MemberSpec, Vec<u8>)>,
2329        /// The largest single `write_chunk` buffer seen (the format reader streams in ≤64 KiB
2330        /// reads, never the whole member — this stays tiny even for a multi-MiB member).
2331        max_write_buf: usize,
2332    }
2333
2334    impl BundleRestorer for RecordingRestorer {
2335        fn restore_sidecar(&mut self, spec: &MemberSpec, temp: &Path) -> Result<(), BundleError> {
2336            self.sidecars.push((spec.clone(), std::fs::read(temp)?));
2337            Ok(())
2338        }
2339        fn begin_chunked(&mut self, spec: &MemberSpec) -> Result<(), BundleError> {
2340            self.begins.push(spec.path.clone());
2341            self.cur = Some(Vec::new());
2342            Ok(())
2343        }
2344        fn write_chunk(&mut self, _spec: &MemberSpec, buf: &[u8]) -> Result<(), BundleError> {
2345            self.max_write_buf = self.max_write_buf.max(buf.len());
2346            self.cur
2347                .as_mut()
2348                .expect("write_chunk between begin and finish")
2349                .extend_from_slice(buf);
2350            Ok(())
2351        }
2352        fn finish_chunked(&mut self, spec: &MemberSpec, verified: bool) -> Result<(), BundleError> {
2353            self.finishes.push((spec.path.clone(), verified));
2354            let bytes = self.cur.take().unwrap_or_default();
2355            if verified {
2356                self.committed_chunked.push((spec.clone(), bytes));
2357            }
2358            Ok(())
2359        }
2360    }
2361
2362    /// Count the `.part*` tar entries of a chunked member in a raw bundle, and the max part size —
2363    /// the structural O(chunk) proof: a body > chunk_size is written as MULTIPLE bounded parts,
2364    /// never one whole-member entry (nor a temp).
2365    fn count_parts(bundle: &[u8], base: &str) -> (usize, u64) {
2366        let mut ar = tar::Archive::new(bundle);
2367        let mut n = 0usize;
2368        let mut max = 0u64;
2369        for e in ar.entries().unwrap() {
2370            let e = e.unwrap();
2371            let p = e.path().unwrap().to_string_lossy().into_owned();
2372            if p.starts_with(&format!("{base}{PART_INFIX}")) {
2373                n += 1;
2374                max = max.max(e.header().size().unwrap());
2375            }
2376        }
2377        (n, max)
2378    }
2379
2380    #[test]
2381    fn v3_chunked_round_trips_with_bounded_part_staging() {
2382        // A chunked member whose body is 5× the chunk size → the writer emits 5 `.part` entries,
2383        // each ≤ chunk_size (NEVER one whole-member entry), and the atomic reader streams them
2384        // back chunk-by-chunk to a round-trip-exact reassembly, committing exactly once.
2385        const CHUNK: usize = 4096;
2386        let body: Vec<u8> = (0..CHUNK * 5).map(|i| (i % 251) as u8).collect();
2387        let specs = vec![
2388            MemberSpec::sidecar(KV_MEMBER_PATH.to_string(), MemberKind::Kv, None),
2389            MemberSpec::chunked(
2390                "libsql/acme/blog/default.sqlite".to_string(),
2391                MemberKind::Libsql,
2392                None,
2393            ),
2394        ];
2395        let td = tempfile::tempdir().unwrap();
2396        let kv_tmp = td.path().join("kv");
2397        std::fs::write(&kv_tmp, b"BRKVDUMP-KV").unwrap();
2398
2399        let body_for_open = body.clone();
2400        let mut bundle: Vec<u8> = Vec::new();
2401        write_bundle_streaming_v3(
2402            &mut bundle,
2403            1_700_000_000,
2404            false,
2405            SourceInfo {
2406                project: "acme".to_string(),
2407                site: None,
2408            },
2409            specs.clone(),
2410            CHUNK,
2411            |_spec| Ok(kv_tmp.clone()),
2412            |_spec| Ok(Box::new(io::Cursor::new(body_for_open.clone())) as Box<dyn Read>),
2413        )
2414        .unwrap();
2415
2416        // The bundle is v3 (a chunked member is present).
2417        assert_eq!(read_manifest_version(&bundle), BUNDLE_FORMAT_VERSION_V3);
2418        // Structural O(chunk) proof: 5 bounded parts, none larger than CHUNK.
2419        let (parts, max_part) = count_parts(&bundle, "libsql/acme/blog/default.sqlite");
2420        assert_eq!(parts, 5, "a 5×chunk body is written as 5 `.part` entries");
2421        assert!(
2422            max_part <= CHUNK as u64,
2423            "no part exceeds the chunk size (never a whole-member entry): {max_part}"
2424        );
2425
2426        // Read it back through the atomic reader.
2427        let stage = td.path().join("stage");
2428        std::fs::create_dir_all(&stage).unwrap();
2429        let mut r = RecordingRestorer::default();
2430        let mut data_only = true;
2431        let restored =
2432            read_bundle_streaming_atomic(bundle.as_slice(), &stage, |d, _s| data_only = d, &mut r)
2433                .unwrap();
2434
2435        assert!(!data_only);
2436        assert_eq!(restored, specs, "both members restored in declared order");
2437        // The KV sidecar applied once; the chunked member committed exactly once with verified.
2438        assert_eq!(r.sidecars.len(), 1);
2439        assert_eq!(r.sidecars[0].1, b"BRKVDUMP-KV");
2440        assert_eq!(
2441            r.begins,
2442            vec!["libsql/acme/blog/default.sqlite".to_string()]
2443        );
2444        assert_eq!(
2445            r.finishes,
2446            vec![("libsql/acme/blog/default.sqlite".to_string(), true)],
2447            "finish_chunked fired ONCE with verified=true (commit)"
2448        );
2449        assert_eq!(r.committed_chunked.len(), 1);
2450        assert_eq!(
2451            r.committed_chunked[0].1, body,
2452            "the chunked member round-trips byte-for-byte"
2453        );
2454        // The format layer NEVER handed the restorer the whole member at once (O(chunk) read).
2455        assert!(
2456            r.max_write_buf <= 64 * 1024,
2457            "reader streams in ≤64 KiB reads, never the whole member: {}",
2458            r.max_write_buf
2459        );
2460
2461        // MUTATION (RED): a writer that wrote the whole member as ONE part (whole-member staging)
2462        // would make `parts == 1` and `max_part == CHUNK*5` — the two assertions above go RED.
2463    }
2464
2465    #[test]
2466    fn v3_chunked_trailer_mismatch_aborts_and_never_commits() {
2467        // Hand-build a v3 bundle whose `.brtrailer` SHA lies — the atomic reader must raise a
2468        // ChecksumMismatch AND tell the sink to DROP (finish_chunked verified=false), never commit.
2469        let base = "libsql/acme/blog/default.sqlite";
2470        let mut buf: Vec<u8> = Vec::new();
2471        {
2472            let mut b = tar::Builder::new(&mut buf);
2473            let manifest = StreamManifest {
2474                magic: BUNDLE_MAGIC.to_string(),
2475                format_version: BUNDLE_FORMAT_VERSION_V3,
2476                created_at: 0,
2477                data_only: true,
2478                source: SourceInfo {
2479                    project: "acme".to_string(),
2480                    site: None,
2481                },
2482                members: vec![MemberSpec::chunked(
2483                    base.to_string(),
2484                    MemberKind::Libsql,
2485                    None,
2486                )],
2487                delta: None,
2488            };
2489            append_bytes(
2490                &mut b,
2491                MANIFEST_PATH,
2492                0,
2493                &serde_json::to_vec(&manifest).unwrap(),
2494            )
2495            .unwrap();
2496            append_bytes(
2497                &mut b,
2498                &chunk_part_path(base, 0),
2499                0,
2500                b"the real member bytes",
2501            )
2502            .unwrap();
2503            let trailer = MemberTrailer {
2504                size: 21,
2505                sha256: "00".repeat(32), // deliberately wrong
2506            };
2507            append_bytes(
2508                &mut b,
2509                &chunk_trailer_path(base),
2510                0,
2511                &serde_json::to_vec(&trailer).unwrap(),
2512            )
2513            .unwrap();
2514            b.finish().unwrap();
2515        }
2516        let td = tempfile::tempdir().unwrap();
2517        let mut r = RecordingRestorer::default();
2518        let res = read_bundle_streaming_atomic(buf.as_slice(), td.path(), |_d, _s| {}, &mut r);
2519        assert!(
2520            matches!(res, Err(BundleError::ChecksumMismatch { .. })),
2521            "a lying trailer is a loud reject: {res:?}"
2522        );
2523        assert_eq!(
2524            r.finishes,
2525            vec![(base.to_string(), false)],
2526            "finish_chunked fired with verified=false (DROP the shadow); commit NEVER fired"
2527        );
2528        assert!(
2529            r.committed_chunked.is_empty(),
2530            "no chunked member is committed when the trailer mismatches"
2531        );
2532    }
2533
2534    #[test]
2535    fn v3_chunked_truncation_before_trailer_aborts() {
2536        // A v3 bundle whose chunked member ends WITHOUT its `.brtrailer` (truncated) → the reader
2537        // raises MalformedStream and drops the sink (finish_chunked verified=false).
2538        let base = "libsql/acme/blog/default.sqlite";
2539        let mut buf: Vec<u8> = Vec::new();
2540        {
2541            let mut b = tar::Builder::new(&mut buf);
2542            let manifest = StreamManifest {
2543                magic: BUNDLE_MAGIC.to_string(),
2544                format_version: BUNDLE_FORMAT_VERSION_V3,
2545                created_at: 0,
2546                data_only: true,
2547                source: SourceInfo {
2548                    project: "acme".to_string(),
2549                    site: None,
2550                },
2551                members: vec![MemberSpec::chunked(
2552                    base.to_string(),
2553                    MemberKind::Libsql,
2554                    None,
2555                )],
2556                delta: None,
2557            };
2558            append_bytes(
2559                &mut b,
2560                MANIFEST_PATH,
2561                0,
2562                &serde_json::to_vec(&manifest).unwrap(),
2563            )
2564            .unwrap();
2565            append_bytes(
2566                &mut b,
2567                &chunk_part_path(base, 0),
2568                0,
2569                b"a chunk but no trailer",
2570            )
2571            .unwrap();
2572            b.finish().unwrap(); // NO `.brtrailer`
2573        }
2574        let td = tempfile::tempdir().unwrap();
2575        let mut r = RecordingRestorer::default();
2576        let res = read_bundle_streaming_atomic(buf.as_slice(), td.path(), |_d, _s| {}, &mut r);
2577        assert!(
2578            matches!(res, Err(BundleError::MalformedStream(_))),
2579            "a truncated chunked member (no trailer) is rejected: {res:?}"
2580        );
2581        assert_eq!(
2582            r.finishes,
2583            vec![(base.to_string(), false)],
2584            "sink DROPPED on truncation"
2585        );
2586        assert!(r.committed_chunked.is_empty());
2587    }
2588
2589    #[test]
2590    fn atomic_reader_reads_v1_and_v2_but_an_inc1_node_loud_rejects_v3() {
2591        // Read-compat: the v3 atomic reader reads a v1 AND a v2 bundle (all sidecar members).
2592        let td = tempfile::tempdir().unwrap();
2593        let stage = td.path().join("stage");
2594        std::fs::create_dir_all(&stage).unwrap();
2595
2596        // v1 bundle via write_bundle.
2597        let kv = write_staging(&stage, "kv.bin", b"V1-KV");
2598        let v1 = td.path().join("v1.brproj");
2599        write_bundle(
2600            &v1,
2601            0,
2602            true,
2603            SourceInfo {
2604                project: "acme".to_string(),
2605                site: None,
2606            },
2607            &[StagedMember {
2608                path: KV_MEMBER_PATH.to_string(),
2609                kind: MemberKind::Kv,
2610                source: kv,
2611                pg: None,
2612            }],
2613        )
2614        .unwrap();
2615        let mut r1 = RecordingRestorer::default();
2616        read_bundle_streaming_atomic(
2617            io::BufReader::new(std::fs::File::open(&v1).unwrap()),
2618            &stage,
2619            |_d, _s| {},
2620            &mut r1,
2621        )
2622        .unwrap();
2623        assert_eq!(r1.sidecars.len(), 1);
2624        assert_eq!(r1.sidecars[0].1, b"V1-KV");
2625
2626        // v2 bundle via write_bundle_streaming (all sidecar).
2627        let gendir = td.path().join("gen");
2628        std::fs::create_dir_all(&gendir).unwrap();
2629        let mut v2: Vec<u8> = Vec::new();
2630        write_bundle_streaming(
2631            &mut v2,
2632            0,
2633            true,
2634            SourceInfo {
2635                project: "acme".to_string(),
2636                site: None,
2637            },
2638            vec![MemberSpec::sidecar(
2639                KV_MEMBER_PATH.to_string(),
2640                MemberKind::Kv,
2641                None,
2642            )],
2643            |_s| {
2644                let p = gendir.join("m");
2645                std::fs::write(&p, b"V2-KV").unwrap();
2646                Ok(p)
2647            },
2648        )
2649        .unwrap();
2650        assert_eq!(read_manifest_version(&v2), BUNDLE_FORMAT_VERSION_V2);
2651        let mut r2 = RecordingRestorer::default();
2652        read_bundle_streaming_atomic(v2.as_slice(), &stage, |_d, _s| {}, &mut r2).unwrap();
2653        assert_eq!(r2.sidecars[0].1, b"V2-KV");
2654
2655        // A v3 bundle (a chunked member) handed to the LEGACY v1/v2 reader → loud BadVersion
2656        // (an inc1 node never silently misreads a v3 bundle).
2657        let mut v3: Vec<u8> = Vec::new();
2658        write_bundle_streaming_v3(
2659            &mut v3,
2660            0,
2661            true,
2662            SourceInfo {
2663                project: "acme".to_string(),
2664                site: None,
2665            },
2666            vec![MemberSpec::chunked(
2667                "libsql/acme/blog/default.sqlite".to_string(),
2668                MemberKind::Libsql,
2669                None,
2670            )],
2671            16,
2672            |_s| unreachable!("no sidecar member"),
2673            |_s| Ok(Box::new(io::Cursor::new(b"hello v3 chunked".to_vec())) as Box<dyn Read>),
2674        )
2675        .unwrap();
2676        assert_eq!(read_manifest_version(&v3), BUNDLE_FORMAT_VERSION_V3);
2677        let legacy = read_bundle_streaming(v3.as_slice(), |_s, _sz, _b| Ok(()));
2678        assert!(
2679            matches!(legacy, Err(BundleError::BadVersion { found: 3, .. })),
2680            "the legacy v1/v2 reader loud-rejects a v3 bundle: {legacy:?}"
2681        );
2682    }
2683
2684    /// Read the MANIFEST's `format_version` from a raw bundle (test helper).
2685    fn read_manifest_version(bundle: &[u8]) -> u32 {
2686        let mut ar = tar::Archive::new(bundle);
2687        let mut first = ar.entries().unwrap().next().unwrap().unwrap();
2688        let mut s = String::new();
2689        first.read_to_string(&mut s).unwrap();
2690        peek_format_version(&s).unwrap()
2691    }
2692}