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}
183
184impl Manifest {
185    /// Loud-reject a foreign or incompatible bundle: the magic and format version MUST
186    /// match this build (mirrors the `kv_dump` discipline).
187    pub fn validate(&self) -> Result<(), BundleError> {
188        if self.magic != BUNDLE_MAGIC {
189            return Err(BundleError::BadMagic {
190                expected: BUNDLE_MAGIC.to_string(),
191                found: self.magic.clone(),
192            });
193        }
194        if self.format_version != BUNDLE_FORMAT_VERSION {
195            return Err(BundleError::BadVersion {
196                found: self.format_version,
197                supported: BUNDLE_FORMAT_VERSION,
198            });
199        }
200        Ok(())
201    }
202
203    /// The `PgDump` members (one per Postgres database).
204    pub fn pg_members(&self) -> impl Iterator<Item = &ManifestMember> {
205        self.members.iter().filter(|m| m.kind == MemberKind::PgDump)
206    }
207}
208
209/// A member staged on disk, ready to be appended to a bundle.
210#[derive(Debug, Clone)]
211pub struct StagedMember {
212    /// The tar-entry path to write it at (relative, no traversal).
213    pub path: String,
214    /// Which engine produced it.
215    pub kind: MemberKind,
216    /// The on-disk staging file holding the member bytes.
217    pub source: PathBuf,
218    /// Postgres provenance (only for `PgDump` members).
219    pub pg: Option<PgMemberInfo>,
220}
221
222/// Reject an absolute or traversing member path (defense on both write and read, so a
223/// malicious bundle can never extract outside its target directory).
224fn validate_member_path(path: &str) -> Result<(), BundleError> {
225    use std::path::Component;
226    if path.is_empty() {
227        return Err(BundleError::UnsafePath(path.to_string()));
228    }
229    let p = Path::new(path);
230    if p.is_absolute() {
231        return Err(BundleError::UnsafePath(path.to_string()));
232    }
233    for c in p.components() {
234        if !matches!(c, Component::Normal(_)) {
235            return Err(BundleError::UnsafePath(path.to_string()));
236        }
237    }
238    Ok(())
239}
240
241/// Stream `r`, returning `(byte_count, hex_sha256)` without buffering it whole.
242fn size_and_sha256<R: Read>(mut r: R) -> io::Result<(u64, String)> {
243    let mut hasher = Sha256::new();
244    let mut buf = [0u8; 64 * 1024];
245    let mut total = 0u64;
246    loop {
247        let n = r.read(&mut buf)?;
248        if n == 0 {
249            break;
250        }
251        hasher.update(&buf[..n]);
252        total += n as u64;
253    }
254    Ok((total, hex::encode(hasher.finalize())))
255}
256
257/// Append `data` as a tar member at `path` (a small in-memory member, e.g. the MANIFEST).
258fn append_bytes<W: Write>(
259    builder: &mut tar::Builder<W>,
260    path: &str,
261    mtime: u64,
262    data: &[u8],
263) -> Result<(), BundleError> {
264    let mut header = tar::Header::new_gnu();
265    header.set_size(data.len() as u64);
266    header.set_mode(0o600);
267    header.set_mtime(mtime);
268    // `append_data` sets the path + checksum itself.
269    builder.append_data(&mut header, path, data)?;
270    Ok(())
271}
272
273/// Write a bundle at `out` from members already staged on disk: compute each member's
274/// size + SHA-256, build + validate the MANIFEST, then write the tar with the MANIFEST
275/// first and every member streamed from its staging file (bounded memory). Returns the
276/// MANIFEST that was written.
277pub fn write_bundle(
278    out: &Path,
279    created_at: u64,
280    data_only: bool,
281    source: SourceInfo,
282    staged: &[StagedMember],
283) -> Result<Manifest, BundleError> {
284    // 1. Describe every member (size + checksum), validating each path.
285    let mut members = Vec::with_capacity(staged.len());
286    for m in staged {
287        validate_member_path(&m.path)?;
288        let f = std::fs::File::open(&m.source)?;
289        let (size, sha256) = size_and_sha256(io::BufReader::new(f))?;
290        members.push(ManifestMember {
291            path: m.path.clone(),
292            kind: m.kind,
293            size,
294            sha256,
295            pg: m.pg.clone(),
296        });
297    }
298    let manifest = Manifest {
299        magic: BUNDLE_MAGIC.to_string(),
300        format_version: BUNDLE_FORMAT_VERSION,
301        created_at,
302        data_only,
303        source,
304        members,
305    };
306    manifest.validate()?; // defensive — we just built it, but keep the one invariant honest
307
308    // 2. Write the tar: MANIFEST first, then each member streamed from its file.
309    let file = std::fs::File::create(out)?;
310    let mut builder = tar::Builder::new(io::BufWriter::new(file));
311    let manifest_json = serde_json::to_vec_pretty(&manifest)?;
312    append_bytes(&mut builder, MANIFEST_PATH, created_at, &manifest_json)?;
313    for (sm, mm) in staged.iter().zip(&manifest.members) {
314        let mut f = std::fs::File::open(&sm.source)?;
315        let mut header = tar::Header::new_gnu();
316        header.set_size(mm.size);
317        header.set_mode(0o600);
318        header.set_mtime(created_at);
319        builder.append_data(&mut header, &sm.path, &mut f)?;
320    }
321    let mut inner = builder.into_inner()?;
322    inner.flush()?;
323    Ok(manifest)
324}
325
326/// Read + validate the MANIFEST (the first tar entry) from `bundle`, without extracting
327/// any member.
328pub fn read_manifest(bundle: &Path) -> Result<Manifest, BundleError> {
329    let file = std::fs::File::open(bundle)?;
330    let mut ar = tar::Archive::new(io::BufReader::new(file));
331    let mut entries = ar.entries()?;
332    let first = entries
333        .next()
334        .ok_or_else(|| BundleError::MissingManifest(MANIFEST_PATH.to_string()))??;
335    let path = first.path()?.to_string_lossy().into_owned();
336    if path != MANIFEST_PATH {
337        return Err(BundleError::MissingManifest(MANIFEST_PATH.to_string()));
338    }
339    let mut buf = String::new();
340    let mut first = first;
341    first.read_to_string(&mut buf)?;
342    let manifest: Manifest = serde_json::from_str(&buf)?;
343    manifest.validate()?;
344    Ok(manifest)
345}
346
347/// Extract every member of `bundle` into `out_dir`, VERIFYING each member's SHA-256
348/// against the MANIFEST as it streams (byte-integrity). Refuses a member the manifest
349/// does not describe, and a manifest member the bundle does not contain. Returns the
350/// validated MANIFEST. Member files land at `out_dir/<member.path>`.
351pub fn extract_and_verify(bundle: &Path, out_dir: &Path) -> Result<Manifest, BundleError> {
352    let manifest = read_manifest(bundle)?;
353    let file = std::fs::File::open(bundle)?;
354    let mut ar = tar::Archive::new(io::BufReader::new(file));
355    let mut seen: HashSet<String> = HashSet::new();
356    for entry in ar.entries()? {
357        let mut entry = entry?;
358        let path = entry.path()?.to_string_lossy().into_owned();
359        if path == MANIFEST_PATH {
360            continue;
361        }
362        validate_member_path(&path)?;
363        let mm = manifest
364            .members
365            .iter()
366            .find(|m| m.path == path)
367            .ok_or_else(|| BundleError::UnexpectedMember(path.clone()))?;
368        let dst = out_dir.join(&path);
369        if let Some(parent) = dst.parent() {
370            std::fs::create_dir_all(parent)?;
371        }
372        // Stream the member to disk while hashing — never buffer the (multi-GB) payload.
373        let mut hasher = Sha256::new();
374        let mut f = io::BufWriter::new(std::fs::File::create(&dst)?);
375        let mut buf = [0u8; 64 * 1024];
376        loop {
377            let n = entry.read(&mut buf)?;
378            if n == 0 {
379                break;
380            }
381            hasher.update(&buf[..n]);
382            f.write_all(&buf[..n])?;
383        }
384        f.flush()?;
385        let actual = hex::encode(hasher.finalize());
386        if actual != mm.sha256 {
387            return Err(BundleError::ChecksumMismatch {
388                path,
389                expected: mm.sha256.clone(),
390                actual,
391            });
392        }
393        seen.insert(path);
394    }
395    for m in &manifest.members {
396        if !seen.contains(&m.path) {
397            return Err(BundleError::MissingMember(m.path.clone()));
398        }
399    }
400    Ok(manifest)
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    fn write_staging(dir: &Path, name: &str, bytes: &[u8]) -> PathBuf {
408        let p = dir.join(name);
409        std::fs::write(&p, bytes).unwrap();
410        p
411    }
412
413    #[test]
414    fn round_trips_a_bundle_and_verifies_members() {
415        let td = tempfile::tempdir().unwrap();
416        let stage = td.path().join("stage");
417        std::fs::create_dir_all(&stage).unwrap();
418        let kv = write_staging(&stage, "kv.bin", b"BRKVDUMP\x01\x02\x03");
419        let pg = write_staging(&stage, "app.pgc", &vec![7u8; 5000]);
420
421        let bundle = td.path().join("out.brproj");
422        let staged = vec![
423            StagedMember {
424                path: KV_MEMBER_PATH.to_string(),
425                kind: MemberKind::Kv,
426                source: kv,
427                pg: None,
428            },
429            StagedMember {
430                path: "sql/app.pgc".to_string(),
431                kind: MemberKind::PgDump,
432                source: pg,
433                pg: Some(PgMemberInfo {
434                    server_version_num: "160004".to_string(),
435                    client_image: "pgvector/pgvector:pg16".to_string(),
436                    schema_fingerprint: Some("deadbeef".to_string()),
437                }),
438            },
439        ];
440        let written = write_bundle(
441            &bundle,
442            1_700_000_000,
443            true,
444            SourceInfo {
445                project: "acme".to_string(),
446                site: None,
447            },
448            &staged,
449        )
450        .unwrap();
451        assert_eq!(written.magic, BUNDLE_MAGIC);
452        assert!(written.data_only);
453        assert_eq!(written.pg_members().count(), 1);
454
455        // Re-read the manifest from disk.
456        let m = read_manifest(&bundle).unwrap();
457        assert_eq!(m, written);
458        assert_eq!(
459            m.pg_members()
460                .next()
461                .unwrap()
462                .pg
463                .as_ref()
464                .unwrap()
465                .client_image,
466            "pgvector/pgvector:pg16"
467        );
468
469        // Extract + verify.
470        let out = td.path().join("extract");
471        let m2 = extract_and_verify(&bundle, &out).unwrap();
472        assert_eq!(m2, written);
473        assert_eq!(
474            std::fs::read(out.join("sql/app.pgc")).unwrap(),
475            vec![7u8; 5000]
476        );
477        assert_eq!(
478            std::fs::read(out.join(KV_MEMBER_PATH)).unwrap(),
479            b"BRKVDUMP\x01\x02\x03"
480        );
481    }
482
483    #[test]
484    fn rejects_a_foreign_tar_and_a_future_version() {
485        // Bad magic: hand-build a tar whose MANIFEST carries the wrong magic.
486        let td = tempfile::tempdir().unwrap();
487        let bad = td.path().join("bad.tar");
488        {
489            let mut b = tar::Builder::new(std::fs::File::create(&bad).unwrap());
490            let m = Manifest {
491                magic: "NOTBRPROJ".to_string(),
492                format_version: BUNDLE_FORMAT_VERSION,
493                created_at: 0,
494                data_only: true,
495                source: SourceInfo {
496                    project: "x".to_string(),
497                    site: None,
498                },
499                members: vec![],
500            };
501            let j = serde_json::to_vec(&m).unwrap();
502            append_bytes(&mut b, MANIFEST_PATH, 0, &j).unwrap();
503            b.finish().unwrap();
504        }
505        assert!(matches!(
506            read_manifest(&bad),
507            Err(BundleError::BadMagic { .. })
508        ));
509
510        // Bad version.
511        let futv = td.path().join("futv.tar");
512        {
513            let mut b = tar::Builder::new(std::fs::File::create(&futv).unwrap());
514            let m = Manifest {
515                magic: BUNDLE_MAGIC.to_string(),
516                format_version: BUNDLE_FORMAT_VERSION + 99,
517                created_at: 0,
518                data_only: true,
519                source: SourceInfo {
520                    project: "x".to_string(),
521                    site: None,
522                },
523                members: vec![],
524            };
525            let j = serde_json::to_vec(&m).unwrap();
526            append_bytes(&mut b, MANIFEST_PATH, 0, &j).unwrap();
527            b.finish().unwrap();
528        }
529        assert!(matches!(
530            read_manifest(&futv),
531            Err(BundleError::BadVersion { .. })
532        ));
533    }
534
535    #[test]
536    fn extract_catches_a_checksum_mismatch() {
537        // Hand-build a bundle whose MANIFEST lies about a member's checksum; extraction
538        // must catch the byte-level mismatch (the verify discipline, load-bearing).
539        let td = tempfile::tempdir().unwrap();
540        let bad = td.path().join("tamper.tar");
541        {
542            let mut b = tar::Builder::new(std::fs::File::create(&bad).unwrap());
543            let m = Manifest {
544                magic: BUNDLE_MAGIC.to_string(),
545                format_version: BUNDLE_FORMAT_VERSION,
546                created_at: 0,
547                data_only: true,
548                source: SourceInfo {
549                    project: "x".to_string(),
550                    site: None,
551                },
552                members: vec![ManifestMember {
553                    path: "sql/app.pgc".to_string(),
554                    kind: MemberKind::PgDump,
555                    size: 3,
556                    sha256: "00".repeat(32), // deliberately wrong
557                    pg: None,
558                }],
559            };
560            let j = serde_json::to_vec(&m).unwrap();
561            append_bytes(&mut b, MANIFEST_PATH, 0, &j).unwrap();
562            append_bytes(&mut b, "sql/app.pgc", 0, b"abc").unwrap();
563            b.finish().unwrap();
564        }
565        let out = td.path().join("x");
566        assert!(matches!(
567            extract_and_verify(&bad, &out),
568            Err(BundleError::ChecksumMismatch { .. })
569        ));
570    }
571
572    #[test]
573    fn rejects_unsafe_member_paths() {
574        assert!(validate_member_path("sql/app.pgc").is_ok());
575        assert!(validate_member_path("libsql/acme/site/db.sqlite").is_ok());
576        assert!(validate_member_path("/etc/passwd").is_err());
577        assert!(validate_member_path("../escape").is_err());
578        assert!(validate_member_path("a/../b").is_err());
579        assert!(validate_member_path("").is_err());
580    }
581}