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