1use std::collections::HashSet;
29use std::io::{self, Read, Write};
30use std::path::{Path, PathBuf};
31
32use serde::{Deserialize, Serialize};
33use sha2::{Digest, Sha256};
34
35pub const BUNDLE_MAGIC: &str = "BRPROJ";
37
38pub const BUNDLE_FORMAT_VERSION: u32 = 1;
41
42pub const MANIFEST_PATH: &str = "MANIFEST";
44
45pub const KV_MEMBER_PATH: &str = "kv.brkvdump";
47
48pub const KV_DELTA_MEMBER_PATH: &str = "kv.brkvdelta";
51
52pub const BLOB_TOMBSTONE_MEMBER_PATH: &str = "blobs.brtombstones";
55
56pub const IDENTITY_MEMBER_PATH: &str = "identity/identity.brid";
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "snake_case")]
63pub enum MemberKind {
64 PgDump,
66 MysqlDump,
68 Libsql,
70 Kv,
72 Blob,
74 Identity,
78 KvDelta,
82 BlobTombstone,
86 PgWalDelta,
91 MysqlBinlogDelta,
94 LibsqlChangeset,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105pub struct PgMemberInfo {
106 pub server_version_num: String,
109 pub client_image: String,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub schema_fingerprint: Option<String>,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122pub struct ManifestMember {
123 pub path: String,
125 pub kind: MemberKind,
127 pub size: u64,
129 pub sha256: String,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub pg: Option<PgMemberInfo>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub sql_cursor: Option<boatramp_core::project_delta::SqlMemberCursor>,
139}
140
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143pub struct SourceInfo {
144 pub project: String,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub site: Option<String>,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153pub struct Manifest {
154 pub magic: String,
156 pub format_version: u32,
158 pub created_at: u64,
160 pub data_only: bool,
162 pub source: SourceInfo,
164 pub members: Vec<ManifestMember>,
166}
167
168#[derive(Debug, thiserror::Error)]
170pub enum BundleError {
171 #[error("bundle io: {0}")]
173 Io(#[from] io::Error),
174 #[error("bundle manifest json: {0}")]
176 Json(#[from] serde_json::Error),
177 #[error("not a boatramp project bundle: expected magic {expected:?}, found {found:?}")]
179 BadMagic {
180 expected: String,
182 found: String,
184 },
185 #[error("unsupported bundle format version {found} (this build reads {supported})")]
187 BadVersion {
188 found: u32,
190 supported: u32,
192 },
193 #[error("bundle is missing its {0:?} entry as the first entry")]
195 MissingManifest(String),
196 #[error("checksum MISMATCH for member {path:?}: manifest {expected}, extracted {actual}")]
198 ChecksumMismatch {
199 path: String,
201 expected: String,
203 actual: String,
205 },
206 #[error("member {0:?} named in the manifest is absent from the bundle")]
208 MissingMember(String),
209 #[error("member {0:?} is present in the bundle but absent from the manifest")]
211 UnexpectedMember(String),
212 #[error("unsafe member path {0:?} in bundle (absolute or traversal)")]
214 UnsafePath(String),
215 #[error("malformed v2 bundle stream: {0}")]
218 MalformedStream(String),
219 #[error("size MISMATCH for member {path:?}: declared {expected}, streamed {actual}")]
221 SizeMismatch {
222 path: String,
224 expected: u64,
226 actual: u64,
228 },
229}
230
231impl Manifest {
232 pub fn validate(&self) -> Result<(), BundleError> {
235 if self.magic != BUNDLE_MAGIC {
236 return Err(BundleError::BadMagic {
237 expected: BUNDLE_MAGIC.to_string(),
238 found: self.magic.clone(),
239 });
240 }
241 if self.format_version != BUNDLE_FORMAT_VERSION {
242 return Err(BundleError::BadVersion {
243 found: self.format_version,
244 supported: BUNDLE_FORMAT_VERSION,
245 });
246 }
247 Ok(())
248 }
249
250 pub fn pg_members(&self) -> impl Iterator<Item = &ManifestMember> {
252 self.members.iter().filter(|m| m.kind == MemberKind::PgDump)
253 }
254}
255
256#[derive(Debug, Clone)]
258pub struct StagedMember {
259 pub path: String,
261 pub kind: MemberKind,
263 pub source: PathBuf,
265 pub pg: Option<PgMemberInfo>,
267}
268
269fn validate_member_path(path: &str) -> Result<(), BundleError> {
272 use std::path::Component;
273 if path.is_empty() {
274 return Err(BundleError::UnsafePath(path.to_string()));
275 }
276 let p = Path::new(path);
277 if p.is_absolute() {
278 return Err(BundleError::UnsafePath(path.to_string()));
279 }
280 for c in p.components() {
281 if !matches!(c, Component::Normal(_)) {
282 return Err(BundleError::UnsafePath(path.to_string()));
283 }
284 }
285 Ok(())
286}
287
288fn size_and_sha256<R: Read>(mut r: R) -> io::Result<(u64, String)> {
290 let mut hasher = Sha256::new();
291 let mut buf = [0u8; 64 * 1024];
292 let mut total = 0u64;
293 loop {
294 let n = r.read(&mut buf)?;
295 if n == 0 {
296 break;
297 }
298 hasher.update(&buf[..n]);
299 total += n as u64;
300 }
301 Ok((total, hex::encode(hasher.finalize())))
302}
303
304fn append_bytes<W: Write>(
306 builder: &mut tar::Builder<W>,
307 path: &str,
308 mtime: u64,
309 data: &[u8],
310) -> Result<(), BundleError> {
311 let mut header = tar::Header::new_gnu();
312 header.set_size(data.len() as u64);
313 header.set_mode(0o600);
314 header.set_mtime(mtime);
315 builder.append_data(&mut header, path, data)?;
317 Ok(())
318}
319
320pub fn write_bundle(
325 out: &Path,
326 created_at: u64,
327 data_only: bool,
328 source: SourceInfo,
329 staged: &[StagedMember],
330) -> Result<Manifest, BundleError> {
331 let mut members = Vec::with_capacity(staged.len());
333 for m in staged {
334 validate_member_path(&m.path)?;
335 let f = std::fs::File::open(&m.source)?;
336 let (size, sha256) = size_and_sha256(io::BufReader::new(f))?;
337 members.push(ManifestMember {
338 path: m.path.clone(),
339 kind: m.kind,
340 size,
341 sha256,
342 pg: m.pg.clone(),
343 sql_cursor: None,
346 });
347 }
348 let manifest = Manifest {
349 magic: BUNDLE_MAGIC.to_string(),
350 format_version: BUNDLE_FORMAT_VERSION,
351 created_at,
352 data_only,
353 source,
354 members,
355 };
356 manifest.validate()?; let file = std::fs::File::create(out)?;
360 let mut builder = tar::Builder::new(io::BufWriter::new(file));
361 let manifest_json = serde_json::to_vec_pretty(&manifest)?;
362 append_bytes(&mut builder, MANIFEST_PATH, created_at, &manifest_json)?;
363 for (sm, mm) in staged.iter().zip(&manifest.members) {
364 let mut f = std::fs::File::open(&sm.source)?;
365 let mut header = tar::Header::new_gnu();
366 header.set_size(mm.size);
367 header.set_mode(0o600);
368 header.set_mtime(created_at);
369 builder.append_data(&mut header, &sm.path, &mut f)?;
370 }
371 let mut inner = builder.into_inner()?;
372 inner.flush()?;
373 Ok(manifest)
374}
375
376pub fn read_manifest(bundle: &Path) -> Result<Manifest, BundleError> {
379 let file = std::fs::File::open(bundle)?;
380 let mut ar = tar::Archive::new(io::BufReader::new(file));
381 let mut entries = ar.entries()?;
382 let first = entries
383 .next()
384 .ok_or_else(|| BundleError::MissingManifest(MANIFEST_PATH.to_string()))??;
385 let path = first.path()?.to_string_lossy().into_owned();
386 if path != MANIFEST_PATH {
387 return Err(BundleError::MissingManifest(MANIFEST_PATH.to_string()));
388 }
389 let mut buf = String::new();
390 let mut first = first;
391 first.read_to_string(&mut buf)?;
392 let manifest: Manifest = serde_json::from_str(&buf)?;
393 manifest.validate()?;
394 Ok(manifest)
395}
396
397pub fn extract_and_verify(bundle: &Path, out_dir: &Path) -> Result<Manifest, BundleError> {
402 let manifest = read_manifest(bundle)?;
403 let file = std::fs::File::open(bundle)?;
404 let mut ar = tar::Archive::new(io::BufReader::new(file));
405 let mut seen: HashSet<String> = HashSet::new();
406 for entry in ar.entries()? {
407 let mut entry = entry?;
408 let path = entry.path()?.to_string_lossy().into_owned();
409 if path == MANIFEST_PATH {
410 continue;
411 }
412 validate_member_path(&path)?;
413 let mm = manifest
414 .members
415 .iter()
416 .find(|m| m.path == path)
417 .ok_or_else(|| BundleError::UnexpectedMember(path.clone()))?;
418 let dst = out_dir.join(&path);
419 if let Some(parent) = dst.parent() {
420 std::fs::create_dir_all(parent)?;
421 }
422 let mut hasher = Sha256::new();
424 let mut f = io::BufWriter::new(std::fs::File::create(&dst)?);
425 let mut buf = [0u8; 64 * 1024];
426 loop {
427 let n = entry.read(&mut buf)?;
428 if n == 0 {
429 break;
430 }
431 hasher.update(&buf[..n]);
432 f.write_all(&buf[..n])?;
433 }
434 f.flush()?;
435 let actual = hex::encode(hasher.finalize());
436 if actual != mm.sha256 {
437 return Err(BundleError::ChecksumMismatch {
438 path,
439 expected: mm.sha256.clone(),
440 actual,
441 });
442 }
443 seen.insert(path);
444 }
445 for m in &manifest.members {
446 if !seen.contains(&m.path) {
447 return Err(BundleError::MissingMember(m.path.clone()));
448 }
449 }
450 Ok(manifest)
451}
452
453pub const BUNDLE_FORMAT_VERSION_V2: u32 = 2;
476
477pub const META_SUFFIX: &str = ".brmeta";
481
482pub const BUNDLE_FORMAT_VERSION_V3: u32 = 3;
508
509pub const TRAILER_SUFFIX: &str = ".brtrailer";
514
515pub const PART_INFIX: &str = ".part";
518
519fn chunk_part_path(base: &str, n: u32) -> String {
521 format!("{base}{PART_INFIX}{n:05}")
522}
523
524fn chunk_trailer_path(base: &str) -> String {
526 format!("{base}{TRAILER_SUFFIX}")
527}
528
529#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
533#[serde(rename_all = "snake_case")]
534pub enum MemberFraming {
535 #[default]
538 Sidecar,
539 ChunkedTrailer,
542}
543
544impl MemberFraming {
545 fn is_sidecar(&self) -> bool {
548 matches!(self, MemberFraming::Sidecar)
549 }
550}
551
552#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
556pub struct MemberSpec {
557 pub path: String,
559 pub kind: MemberKind,
561 #[serde(default, skip_serializing_if = "MemberFraming::is_sidecar")]
566 pub framing: MemberFraming,
567 #[serde(default, skip_serializing_if = "Option::is_none")]
569 pub pg: Option<PgMemberInfo>,
570 #[serde(default, skip_serializing_if = "Option::is_none")]
574 pub sql_cursor: Option<boatramp_core::project_delta::SqlMemberCursor>,
575}
576
577impl MemberSpec {
578 pub fn sidecar(path: String, kind: MemberKind, pg: Option<PgMemberInfo>) -> Self {
580 Self {
581 path,
582 kind,
583 framing: MemberFraming::Sidecar,
584 pg,
585 sql_cursor: None,
586 }
587 }
588
589 pub fn chunked(path: String, kind: MemberKind, pg: Option<PgMemberInfo>) -> Self {
591 Self {
592 path,
593 kind,
594 framing: MemberFraming::ChunkedTrailer,
595 pg,
596 sql_cursor: None,
597 }
598 }
599
600 #[must_use]
603 pub fn with_sql_cursor(
604 mut self,
605 cursor: boatramp_core::project_delta::SqlMemberCursor,
606 ) -> Self {
607 self.sql_cursor = Some(cursor);
608 self
609 }
610}
611
612#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
616pub struct MemberTrailer {
617 pub size: u64,
619 pub sha256: String,
621}
622
623#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
627pub struct StreamManifest {
628 pub magic: String,
630 pub format_version: u32,
632 pub created_at: u64,
634 pub data_only: bool,
636 pub source: SourceInfo,
638 pub members: Vec<MemberSpec>,
640 #[serde(default, skip_serializing_if = "Option::is_none")]
644 pub delta: Option<boatramp_core::project_delta::DeltaInfo>,
645}
646
647impl StreamManifest {
648 fn validate(&self) -> Result<(), BundleError> {
653 if self.magic != BUNDLE_MAGIC {
654 return Err(BundleError::BadMagic {
655 expected: BUNDLE_MAGIC.to_string(),
656 found: self.magic.clone(),
657 });
658 }
659 if self.format_version != BUNDLE_FORMAT_VERSION_V2
660 && self.format_version != BUNDLE_FORMAT_VERSION_V3
661 {
662 return Err(BundleError::BadVersion {
663 found: self.format_version,
664 supported: BUNDLE_FORMAT_VERSION_V3,
665 });
666 }
667 Ok(())
668 }
669
670 fn meta_path(path: &str) -> String {
672 format!("{path}{META_SUFFIX}")
673 }
674}
675
676#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
679pub struct MemberMeta {
680 pub size: u64,
682 pub sha256: String,
684}
685
686pub struct BundleStreamWriter<W: Write> {
694 builder: tar::Builder<W>,
695 created_at: u64,
696 remaining: std::collections::VecDeque<MemberSpec>,
699}
700
701impl<W: Write> BundleStreamWriter<W> {
702 pub fn new(
711 out: W,
712 created_at: u64,
713 data_only: bool,
714 source: SourceInfo,
715 specs: Vec<MemberSpec>,
716 ) -> Result<Self, BundleError> {
717 for s in &specs {
718 validate_member_path(&s.path)?;
719 }
720 let format_version = if specs
721 .iter()
722 .any(|s| s.framing == MemberFraming::ChunkedTrailer)
723 {
724 BUNDLE_FORMAT_VERSION_V3
725 } else {
726 BUNDLE_FORMAT_VERSION_V2
727 };
728 let manifest = StreamManifest {
729 magic: BUNDLE_MAGIC.to_string(),
730 format_version,
731 created_at,
732 data_only,
733 source,
734 members: specs.clone(),
735 delta: None,
738 };
739 manifest.validate()?;
740 let mut builder = tar::Builder::new(out);
741 let manifest_json = serde_json::to_vec_pretty(&manifest)?;
742 append_bytes(&mut builder, MANIFEST_PATH, created_at, &manifest_json)?;
743 Ok(Self {
744 builder,
745 created_at,
746 remaining: specs.into_iter().collect(),
747 })
748 }
749
750 fn next_expected(
755 &mut self,
756 path: &str,
757 kind: MemberKind,
758 framing: MemberFraming,
759 ) -> Result<(), BundleError> {
760 let expected = self.remaining.pop_front().ok_or_else(|| {
761 BundleError::MalformedStream(format!(
762 "append of {path:?} but the MANIFEST declared no more members"
763 ))
764 })?;
765 if expected.path != path || expected.kind != kind || expected.framing != framing {
766 return Err(BundleError::MalformedStream(format!(
767 "append of ({path:?}, {kind:?}, {framing:?}) does not match the next declared \
768 member ({:?}, {:?}, {:?})",
769 expected.path, expected.kind, expected.framing
770 )));
771 }
772 validate_member_path(path)
773 }
774
775 pub fn append_member(
780 &mut self,
781 path: &str,
782 kind: MemberKind,
783 src: &Path,
784 ) -> Result<(), BundleError> {
785 self.next_expected(path, kind, MemberFraming::Sidecar)?;
786
787 let size = std::fs::metadata(src)?.len();
789 let (hashed, sha256) = size_and_sha256(io::BufReader::new(std::fs::File::open(src)?))?;
790 if hashed != size {
791 return Err(BundleError::SizeMismatch {
793 path: path.to_string(),
794 expected: size,
795 actual: hashed,
796 });
797 }
798 let meta = MemberMeta { size, sha256 };
799 let meta_json = serde_json::to_vec(&meta)?;
800 append_bytes(
801 &mut self.builder,
802 &StreamManifest::meta_path(path),
803 self.created_at,
804 &meta_json,
805 )?;
806
807 let mut f = std::fs::File::open(src)?;
809 let mut header = tar::Header::new_gnu();
810 header.set_size(size);
811 header.set_mode(0o600);
812 header.set_mtime(self.created_at);
813 self.builder.append_data(&mut header, path, &mut f)?;
814 Ok(())
815 }
816
817 pub fn append_member_chunked(
826 &mut self,
827 path: &str,
828 kind: MemberKind,
829 body: &mut dyn Read,
830 chunk_size: usize,
831 ) -> Result<(), BundleError> {
832 self.next_expected(path, kind, MemberFraming::ChunkedTrailer)?;
833 let chunk_size = chunk_size.max(1);
834
835 let mut hasher = Sha256::new();
836 let mut total = 0u64;
837 let mut part: u32 = 0;
838 let mut buf = vec![0u8; chunk_size];
839 loop {
840 let mut filled = 0usize;
843 while filled < chunk_size {
844 let n = body.read(&mut buf[filled..])?;
845 if n == 0 {
846 break;
847 }
848 filled += n;
849 }
850 if filled == 0 {
851 break; }
853 hasher.update(&buf[..filled]);
854 total += filled as u64;
855 let mut header = tar::Header::new_gnu();
856 header.set_size(filled as u64);
857 header.set_mode(0o600);
858 header.set_mtime(self.created_at);
859 self.builder
860 .append_data(&mut header, chunk_part_path(path, part), &buf[..filled])?;
861 part = part.checked_add(1).ok_or_else(|| {
862 BundleError::MalformedStream(format!("chunked member {path:?} exceeded 2^32 parts"))
863 })?;
864 if filled < chunk_size {
865 break; }
867 }
868
869 let trailer = MemberTrailer {
871 size: total,
872 sha256: hex::encode(hasher.finalize()),
873 };
874 let trailer_json = serde_json::to_vec(&trailer)?;
875 append_bytes(
876 &mut self.builder,
877 &chunk_trailer_path(path),
878 self.created_at,
879 &trailer_json,
880 )?;
881 Ok(())
882 }
883
884 pub fn finish(mut self) -> Result<(), BundleError> {
886 if let Some(missing) = self.remaining.pop_front() {
887 return Err(BundleError::MissingMember(missing.path));
888 }
889 let mut inner = self.builder.into_inner()?;
890 inner.flush()?;
891 Ok(())
892 }
893}
894
895pub fn write_bundle_streaming<W, S>(
906 out: W,
907 created_at: u64,
908 data_only: bool,
909 source: SourceInfo,
910 specs: Vec<MemberSpec>,
911 mut stage: S,
912) -> Result<(), BundleError>
913where
914 W: Write,
915 S: FnMut(&MemberSpec) -> Result<PathBuf, BundleError>,
916{
917 let mut writer = BundleStreamWriter::new(out, created_at, data_only, source, specs.clone())?;
918 for spec in &specs {
919 let tmp = stage(spec)?;
920 let appended = writer.append_member(&spec.path, spec.kind, &tmp);
921 let _ = std::fs::remove_file(&tmp);
924 appended?;
925 }
926 writer.finish()
927}
928
929#[allow(clippy::too_many_arguments)] pub fn write_bundle_streaming_v3<W, S, O>(
940 out: W,
941 created_at: u64,
942 data_only: bool,
943 source: SourceInfo,
944 specs: Vec<MemberSpec>,
945 chunk_size: usize,
946 mut stage: S,
947 mut open: O,
948) -> Result<(), BundleError>
949where
950 W: Write,
951 S: FnMut(&MemberSpec) -> Result<PathBuf, BundleError>,
952 O: FnMut(&MemberSpec) -> Result<Box<dyn Read>, BundleError>,
953{
954 let mut writer = BundleStreamWriter::new(out, created_at, data_only, source, specs.clone())?;
955 for spec in &specs {
956 match spec.framing {
957 MemberFraming::Sidecar => {
958 let tmp = stage(spec)?;
959 let appended = writer.append_member(&spec.path, spec.kind, &tmp);
960 let _ = std::fs::remove_file(&tmp);
961 appended?;
962 }
963 MemberFraming::ChunkedTrailer => {
964 let mut body = open(spec)?;
965 writer.append_member_chunked(&spec.path, spec.kind, body.as_mut(), chunk_size)?;
966 }
967 }
968 }
969 writer.finish()
970}
971
972struct HashingReader<R: Read> {
976 inner: R,
977 hasher: Sha256,
978 count: u64,
979}
980
981impl<R: Read> HashingReader<R> {
982 fn new(inner: R) -> Self {
983 Self {
984 inner,
985 hasher: Sha256::new(),
986 count: 0,
987 }
988 }
989
990 fn finish(self) -> (u64, String) {
991 (self.count, hex::encode(self.hasher.finalize()))
992 }
993}
994
995impl<R: Read> Read for HashingReader<R> {
996 fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
997 let n = self.inner.read(buf)?;
998 if n > 0 {
999 self.hasher.update(&buf[..n]);
1000 self.count += n as u64;
1001 }
1002 Ok(n)
1003 }
1004}
1005
1006fn peek_format_version(manifest_json: &str) -> Result<u32, BundleError> {
1009 #[derive(Deserialize)]
1010 struct VersionPeek {
1011 #[serde(default)]
1012 magic: String,
1013 format_version: u32,
1014 }
1015 let peek: VersionPeek = serde_json::from_str(manifest_json)?;
1016 if peek.magic != BUNDLE_MAGIC {
1017 return Err(BundleError::BadMagic {
1018 expected: BUNDLE_MAGIC.to_string(),
1019 found: peek.magic,
1020 });
1021 }
1022 Ok(peek.format_version)
1023}
1024
1025fn verify_member<F>(
1030 body: &mut dyn Read,
1031 spec: &MemberSpec,
1032 expected_size: u64,
1033 expected_sha: &str,
1034 restore: &mut F,
1035) -> Result<(), BundleError>
1036where
1037 F: FnMut(&MemberSpec, u64, &mut dyn Read) -> Result<(), BundleError>,
1038{
1039 let mut counting = HashingReader::new(body);
1040 restore(spec, expected_size, &mut counting)?;
1041 let mut sink = [0u8; 64 * 1024];
1043 while counting.read(&mut sink)? != 0 {}
1044 let (count, sha) = counting.finish();
1045 if count != expected_size {
1046 return Err(BundleError::SizeMismatch {
1047 path: spec.path.clone(),
1048 expected: expected_size,
1049 actual: count,
1050 });
1051 }
1052 if sha != expected_sha {
1053 return Err(BundleError::ChecksumMismatch {
1054 path: spec.path.clone(),
1055 expected: expected_sha.to_string(),
1056 actual: sha,
1057 });
1058 }
1059 Ok(())
1060}
1061
1062fn stage_and_verify_member<F>(
1069 body: &mut dyn Read,
1070 spec: &MemberSpec,
1071 expected_size: u64,
1072 expected_sha: &str,
1073 stage_dir: &Path,
1074 counter: &mut u64,
1075 restore: &mut F,
1076) -> Result<(), BundleError>
1077where
1078 F: FnMut(&MemberSpec, &Path) -> Result<(), BundleError>,
1079{
1080 let temp = stage_dir.join(format!("brimport-{counter}"));
1081 *counter += 1;
1082 let mut hasher = Sha256::new();
1083 let mut total = 0u64;
1084 {
1085 let mut f = io::BufWriter::new(std::fs::File::create(&temp)?);
1086 let mut buf = [0u8; 64 * 1024];
1087 loop {
1088 let n = body.read(&mut buf)?;
1089 if n == 0 {
1090 break;
1091 }
1092 hasher.update(&buf[..n]);
1093 total += n as u64;
1094 f.write_all(&buf[..n])?;
1095 }
1096 f.flush()?;
1097 }
1098 let sha = hex::encode(hasher.finalize());
1099 if total != expected_size {
1100 let _ = std::fs::remove_file(&temp);
1101 return Err(BundleError::SizeMismatch {
1102 path: spec.path.clone(),
1103 expected: expected_size,
1104 actual: total,
1105 });
1106 }
1107 if sha != expected_sha {
1108 let _ = std::fs::remove_file(&temp);
1109 return Err(BundleError::ChecksumMismatch {
1110 path: spec.path.clone(),
1111 expected: expected_sha.to_string(),
1112 actual: sha,
1113 });
1114 }
1115 let r = restore(spec, &temp);
1117 let _ = std::fs::remove_file(&temp);
1118 r
1119}
1120
1121pub fn read_bundle_streaming<R, F>(src: R, restore: F) -> Result<Vec<MemberSpec>, BundleError>
1134where
1135 R: Read,
1136 F: FnMut(&MemberSpec, u64, &mut dyn Read) -> Result<(), BundleError>,
1137{
1138 read_bundle_streaming_with_head(src, |_data_only, _source| {}, restore)
1139}
1140
1141pub fn read_bundle_streaming_with_head<R, H, F>(
1149 src: R,
1150 on_head: H,
1151 mut restore: F,
1152) -> Result<Vec<MemberSpec>, BundleError>
1153where
1154 R: Read,
1155 H: FnMut(bool, &SourceInfo),
1156 F: FnMut(&MemberSpec, u64, &mut dyn Read) -> Result<(), BundleError>,
1157{
1158 read_bundle_core(src, on_head, |spec, size, sha, body| {
1159 verify_member(body, spec, size, sha, &mut restore)
1160 })
1161}
1162
1163pub fn read_bundle_streaming_staged<R, H, F>(
1175 src: R,
1176 stage_dir: &Path,
1177 on_head: H,
1178 mut restore: F,
1179) -> Result<Vec<MemberSpec>, BundleError>
1180where
1181 R: Read,
1182 H: FnMut(bool, &SourceInfo),
1183 F: FnMut(&MemberSpec, &Path) -> Result<(), BundleError>,
1184{
1185 let mut counter = 0u64;
1186 read_bundle_core(src, on_head, |spec, size, sha, body| {
1187 stage_and_verify_member(body, spec, size, sha, stage_dir, &mut counter, &mut restore)
1188 })
1189}
1190
1191fn read_bundle_core<R, H, M>(
1198 src: R,
1199 mut on_head: H,
1200 mut per_member: M,
1201) -> Result<Vec<MemberSpec>, BundleError>
1202where
1203 R: Read,
1204 H: FnMut(bool, &SourceInfo),
1205 M: FnMut(&MemberSpec, u64, &str, &mut dyn Read) -> Result<(), BundleError>,
1206{
1207 let mut ar = tar::Archive::new(src);
1208 let mut entries = ar.entries()?;
1209
1210 let mut first = entries
1212 .next()
1213 .ok_or_else(|| BundleError::MissingManifest(MANIFEST_PATH.to_string()))??;
1214 let fpath = first.path()?.to_string_lossy().into_owned();
1215 if fpath != MANIFEST_PATH {
1216 return Err(BundleError::MissingManifest(MANIFEST_PATH.to_string()));
1217 }
1218 let mut manifest_json = String::new();
1219 first.read_to_string(&mut manifest_json)?;
1220 let version = peek_format_version(&manifest_json)?;
1221 let mut restored: Vec<MemberSpec> = Vec::new();
1222
1223 if version == BUNDLE_FORMAT_VERSION {
1224 let manifest: Manifest = serde_json::from_str(&manifest_json)?;
1226 manifest.validate()?;
1227 on_head(manifest.data_only, &manifest.source);
1228 let mut seen: HashSet<String> = HashSet::new();
1229 for entry in entries {
1230 let mut entry = entry?;
1231 let path = entry.path()?.to_string_lossy().into_owned();
1232 validate_member_path(&path)?;
1233 let mm = manifest
1234 .members
1235 .iter()
1236 .find(|m| m.path == path)
1237 .ok_or_else(|| BundleError::UnexpectedMember(path.clone()))?;
1238 let spec = MemberSpec::sidecar(mm.path.clone(), mm.kind, mm.pg.clone());
1239 per_member(&spec, mm.size, &mm.sha256, &mut entry)?;
1240 seen.insert(path);
1241 restored.push(spec);
1242 }
1243 for m in &manifest.members {
1244 if !seen.contains(&m.path) {
1245 return Err(BundleError::MissingMember(m.path.clone()));
1246 }
1247 }
1248 } else if version == BUNDLE_FORMAT_VERSION_V2 {
1249 let manifest: StreamManifest = serde_json::from_str(&manifest_json)?;
1251 manifest.validate()?;
1252 on_head(manifest.data_only, &manifest.source);
1253 let mut specs = manifest.members.iter();
1254 let mut pending_meta: Option<(String, MemberMeta)> = None;
1255 for entry in entries {
1256 let mut entry = entry?;
1257 let path = entry.path()?.to_string_lossy().into_owned();
1258 if let Some(stripped) = path.strip_suffix(META_SUFFIX) {
1259 if pending_meta.is_some() {
1261 return Err(BundleError::MalformedStream(format!(
1262 "sidecar {path:?} arrived with a prior sidecar still unmatched"
1263 )));
1264 }
1265 let mut buf = String::new();
1266 entry.read_to_string(&mut buf)?;
1267 let meta: MemberMeta = serde_json::from_str(&buf)?;
1268 pending_meta = Some((stripped.to_string(), meta));
1269 continue;
1270 }
1271 validate_member_path(&path)?;
1272 let (meta_path, meta) = pending_meta.take().ok_or_else(|| {
1275 BundleError::MalformedStream(format!("member {path:?} with no preceding sidecar"))
1276 })?;
1277 if meta_path != path {
1278 return Err(BundleError::MalformedStream(format!(
1279 "sidecar for {meta_path:?} does not match the following member {path:?}"
1280 )));
1281 }
1282 let spec = specs
1283 .next()
1284 .ok_or_else(|| BundleError::UnexpectedMember(path.clone()))?;
1285 if spec.path != path {
1286 return Err(BundleError::MalformedStream(format!(
1287 "member {path:?} out of declared order (MANIFEST expected {:?})",
1288 spec.path
1289 )));
1290 }
1291 per_member(spec, meta.size, &meta.sha256, &mut entry)?;
1292 restored.push(spec.clone());
1293 }
1294 if let Some((mp, _)) = pending_meta.take() {
1295 return Err(BundleError::MissingMember(mp));
1296 }
1297 if let Some(missing) = specs.next() {
1298 return Err(BundleError::MissingMember(missing.path.clone()));
1299 }
1300 } else {
1301 return Err(BundleError::BadVersion {
1302 found: version,
1303 supported: BUNDLE_FORMAT_VERSION_V2,
1304 });
1305 }
1306 Ok(restored)
1307}
1308
1309pub trait BundleRestorer {
1323 fn restore_sidecar(&mut self, spec: &MemberSpec, temp: &Path) -> Result<(), BundleError>;
1326 fn begin_chunked(&mut self, spec: &MemberSpec) -> Result<(), BundleError>;
1329 fn write_chunk(&mut self, spec: &MemberSpec, buf: &[u8]) -> Result<(), BundleError>;
1332 fn finish_chunked(&mut self, spec: &MemberSpec, verified: bool) -> Result<(), BundleError>;
1335}
1336
1337pub fn read_bundle_streaming_atomic<R, H>(
1349 src: R,
1350 stage_dir: &Path,
1351 mut on_head: H,
1352 restorer: &mut dyn BundleRestorer,
1353) -> Result<Vec<MemberSpec>, BundleError>
1354where
1355 R: Read,
1356 H: FnMut(bool, &SourceInfo),
1357{
1358 let mut ar = tar::Archive::new(src);
1359 let mut entries = ar.entries()?;
1360
1361 let mut first = entries
1363 .next()
1364 .ok_or_else(|| BundleError::MissingManifest(MANIFEST_PATH.to_string()))??;
1365 let fpath = first.path()?.to_string_lossy().into_owned();
1366 if fpath != MANIFEST_PATH {
1367 return Err(BundleError::MissingManifest(MANIFEST_PATH.to_string()));
1368 }
1369 let mut manifest_json = String::new();
1370 first.read_to_string(&mut manifest_json)?;
1371 let version = peek_format_version(&manifest_json)?;
1372 let mut counter = 0u64;
1373 let mut restored: Vec<MemberSpec> = Vec::new();
1374
1375 if version == BUNDLE_FORMAT_VERSION {
1376 let manifest: Manifest = serde_json::from_str(&manifest_json)?;
1379 manifest.validate()?;
1380 on_head(manifest.data_only, &manifest.source);
1381 let mut seen: HashSet<String> = HashSet::new();
1382 for entry in entries {
1383 let mut entry = entry?;
1384 let path = entry.path()?.to_string_lossy().into_owned();
1385 validate_member_path(&path)?;
1386 let mm = manifest
1387 .members
1388 .iter()
1389 .find(|m| m.path == path)
1390 .ok_or_else(|| BundleError::UnexpectedMember(path.clone()))?;
1391 let spec = MemberSpec::sidecar(mm.path.clone(), mm.kind, mm.pg.clone());
1392 stage_and_verify_member(
1393 &mut entry,
1394 &spec,
1395 mm.size,
1396 &mm.sha256,
1397 stage_dir,
1398 &mut counter,
1399 &mut |s, t| restorer.restore_sidecar(s, t),
1400 )?;
1401 seen.insert(path);
1402 restored.push(spec);
1403 }
1404 for m in &manifest.members {
1405 if !seen.contains(&m.path) {
1406 return Err(BundleError::MissingMember(m.path.clone()));
1407 }
1408 }
1409 } else if version == BUNDLE_FORMAT_VERSION_V2 || version == BUNDLE_FORMAT_VERSION_V3 {
1410 let manifest: StreamManifest = serde_json::from_str(&manifest_json)?;
1413 manifest.validate()?;
1414 on_head(manifest.data_only, &manifest.source);
1415 for spec in manifest.members {
1416 match spec.framing {
1417 MemberFraming::Sidecar => {
1418 let mut meta_entry = entries
1420 .next()
1421 .ok_or_else(|| BundleError::MissingMember(spec.path.clone()))??;
1422 let mpath = meta_entry.path()?.to_string_lossy().into_owned();
1423 let stripped = mpath.strip_suffix(META_SUFFIX).ok_or_else(|| {
1424 BundleError::MalformedStream(format!(
1425 "expected sidecar {:?} for member {:?}, found {mpath:?}",
1426 StreamManifest::meta_path(&spec.path),
1427 spec.path
1428 ))
1429 })?;
1430 if stripped != spec.path {
1431 return Err(BundleError::MalformedStream(format!(
1432 "sidecar {mpath:?} does not match the declared member {:?}",
1433 spec.path
1434 )));
1435 }
1436 let mut mbuf = String::new();
1437 meta_entry.read_to_string(&mut mbuf)?;
1438 let meta: MemberMeta = serde_json::from_str(&mbuf)?;
1439
1440 let mut body_entry = entries
1441 .next()
1442 .ok_or_else(|| BundleError::MissingMember(spec.path.clone()))??;
1443 let bpath = body_entry.path()?.to_string_lossy().into_owned();
1444 validate_member_path(&bpath)?;
1445 if bpath != spec.path {
1446 return Err(BundleError::MalformedStream(format!(
1447 "member {bpath:?} out of declared order (MANIFEST expected {:?})",
1448 spec.path
1449 )));
1450 }
1451 stage_and_verify_member(
1452 &mut body_entry,
1453 &spec,
1454 meta.size,
1455 &meta.sha256,
1456 stage_dir,
1457 &mut counter,
1458 &mut |s, t| restorer.restore_sidecar(s, t),
1459 )?;
1460 restored.push(spec);
1461 }
1462 MemberFraming::ChunkedTrailer => {
1463 read_chunked_member(&mut entries, &spec, restorer)?;
1464 restored.push(spec);
1465 }
1466 }
1467 }
1468 if let Some(extra) = entries.next() {
1470 let extra = extra?;
1471 let path = extra.path()?.to_string_lossy().into_owned();
1472 return Err(BundleError::UnexpectedMember(path));
1473 }
1474 } else {
1475 return Err(BundleError::BadVersion {
1476 found: version,
1477 supported: BUNDLE_FORMAT_VERSION_V3,
1478 });
1479 }
1480 Ok(restored)
1481}
1482
1483fn read_chunked_member<R: Read>(
1489 entries: &mut tar::Entries<'_, R>,
1490 spec: &MemberSpec,
1491 restorer: &mut dyn BundleRestorer,
1492) -> Result<(), BundleError> {
1493 restorer.begin_chunked(spec)?;
1494 let mut hasher = Sha256::new();
1495 let mut total: u64 = 0;
1496 let mut next_part: u32 = 0;
1497 let mut outcome: Result<MemberTrailer, BundleError> =
1499 Err(BundleError::MalformedStream(format!(
1500 "chunked member {:?} ended before its {TRAILER_SUFFIX} trailer",
1501 spec.path
1502 )));
1503 loop {
1504 let entry = match entries.next() {
1505 Some(Ok(e)) => e,
1506 Some(Err(e)) => {
1507 outcome = Err(BundleError::Io(e));
1508 break;
1509 }
1510 None => break, };
1512 let mut entry = entry;
1513 let epath = match entry.path() {
1514 Ok(p) => p.to_string_lossy().into_owned(),
1515 Err(e) => {
1516 outcome = Err(BundleError::Io(e));
1517 break;
1518 }
1519 };
1520 if let Some(stripped) = epath.strip_suffix(TRAILER_SUFFIX) {
1521 if stripped != spec.path {
1522 outcome = Err(BundleError::MalformedStream(format!(
1523 "trailer {epath:?} does not match the chunked member {:?}",
1524 spec.path
1525 )));
1526 break;
1527 }
1528 let mut tbuf = String::new();
1529 if let Err(e) = entry.read_to_string(&mut tbuf) {
1530 outcome = Err(BundleError::Io(e));
1531 break;
1532 }
1533 outcome = serde_json::from_str::<MemberTrailer>(&tbuf).map_err(BundleError::Json);
1534 break;
1535 }
1536 let expected = chunk_part_path(&spec.path, next_part);
1538 if epath != expected {
1539 outcome = Err(BundleError::MalformedStream(format!(
1540 "chunked member {:?}: expected chunk {expected:?}, found {epath:?} \
1541 (missing/out-of-order part or wrong member)",
1542 spec.path
1543 )));
1544 break;
1545 }
1546 let mut rbuf = [0u8; 64 * 1024];
1548 let part_res: Result<(), BundleError> = loop {
1549 match entry.read(&mut rbuf) {
1550 Ok(0) => break Ok(()),
1551 Ok(n) => {
1552 hasher.update(&rbuf[..n]);
1553 total += n as u64;
1554 if let Err(e) = restorer.write_chunk(spec, &rbuf[..n]) {
1555 break Err(e);
1556 }
1557 }
1558 Err(e) => break Err(BundleError::Io(e)),
1559 }
1560 };
1561 if let Err(e) = part_res {
1562 outcome = Err(e);
1563 break;
1564 }
1565 next_part = match next_part.checked_add(1) {
1566 Some(v) => v,
1567 None => {
1568 outcome = Err(BundleError::MalformedStream(format!(
1569 "chunked member {:?} exceeded 2^32 parts",
1570 spec.path
1571 )));
1572 break;
1573 }
1574 };
1575 }
1576 match outcome {
1577 Ok(trailer) => {
1578 let actual = hex::encode(hasher.finalize());
1579 let verified = total == trailer.size && actual == trailer.sha256;
1580 restorer.finish_chunked(spec, verified)?;
1583 if total != trailer.size {
1584 return Err(BundleError::SizeMismatch {
1585 path: spec.path.clone(),
1586 expected: trailer.size,
1587 actual: total,
1588 });
1589 }
1590 if actual != trailer.sha256 {
1591 return Err(BundleError::ChecksumMismatch {
1592 path: spec.path.clone(),
1593 expected: trailer.sha256,
1594 actual,
1595 });
1596 }
1597 Ok(())
1598 }
1599 Err(e) => {
1600 let _ = restorer.finish_chunked(spec, false);
1602 Err(e)
1603 }
1604 }
1605}
1606
1607#[cfg(test)]
1608mod tests {
1609 use super::*;
1610
1611 fn write_staging(dir: &Path, name: &str, bytes: &[u8]) -> PathBuf {
1612 let p = dir.join(name);
1613 std::fs::write(&p, bytes).unwrap();
1614 p
1615 }
1616
1617 #[test]
1618 fn round_trips_a_bundle_and_verifies_members() {
1619 let td = tempfile::tempdir().unwrap();
1620 let stage = td.path().join("stage");
1621 std::fs::create_dir_all(&stage).unwrap();
1622 let kv = write_staging(&stage, "kv.bin", b"BRKVDUMP\x01\x02\x03");
1623 let pg = write_staging(&stage, "app.pgc", &vec![7u8; 5000]);
1624
1625 let bundle = td.path().join("out.brproj");
1626 let staged = vec![
1627 StagedMember {
1628 path: KV_MEMBER_PATH.to_string(),
1629 kind: MemberKind::Kv,
1630 source: kv,
1631 pg: None,
1632 },
1633 StagedMember {
1634 path: "sql/app.pgc".to_string(),
1635 kind: MemberKind::PgDump,
1636 source: pg,
1637 pg: Some(PgMemberInfo {
1638 server_version_num: "160004".to_string(),
1639 client_image: "pgvector/pgvector:pg16".to_string(),
1640 schema_fingerprint: Some("deadbeef".to_string()),
1641 }),
1642 },
1643 ];
1644 let written = write_bundle(
1645 &bundle,
1646 1_700_000_000,
1647 true,
1648 SourceInfo {
1649 project: "acme".to_string(),
1650 site: None,
1651 },
1652 &staged,
1653 )
1654 .unwrap();
1655 assert_eq!(written.magic, BUNDLE_MAGIC);
1656 assert!(written.data_only);
1657 assert_eq!(written.pg_members().count(), 1);
1658
1659 let m = read_manifest(&bundle).unwrap();
1661 assert_eq!(m, written);
1662 assert_eq!(
1663 m.pg_members()
1664 .next()
1665 .unwrap()
1666 .pg
1667 .as_ref()
1668 .unwrap()
1669 .client_image,
1670 "pgvector/pgvector:pg16"
1671 );
1672
1673 let out = td.path().join("extract");
1675 let m2 = extract_and_verify(&bundle, &out).unwrap();
1676 assert_eq!(m2, written);
1677 assert_eq!(
1678 std::fs::read(out.join("sql/app.pgc")).unwrap(),
1679 vec![7u8; 5000]
1680 );
1681 assert_eq!(
1682 std::fs::read(out.join(KV_MEMBER_PATH)).unwrap(),
1683 b"BRKVDUMP\x01\x02\x03"
1684 );
1685 }
1686
1687 #[test]
1688 fn rejects_a_foreign_tar_and_a_future_version() {
1689 let td = tempfile::tempdir().unwrap();
1691 let bad = td.path().join("bad.tar");
1692 {
1693 let mut b = tar::Builder::new(std::fs::File::create(&bad).unwrap());
1694 let m = Manifest {
1695 magic: "NOTBRPROJ".to_string(),
1696 format_version: BUNDLE_FORMAT_VERSION,
1697 created_at: 0,
1698 data_only: true,
1699 source: SourceInfo {
1700 project: "x".to_string(),
1701 site: None,
1702 },
1703 members: vec![],
1704 };
1705 let j = serde_json::to_vec(&m).unwrap();
1706 append_bytes(&mut b, MANIFEST_PATH, 0, &j).unwrap();
1707 b.finish().unwrap();
1708 }
1709 assert!(matches!(
1710 read_manifest(&bad),
1711 Err(BundleError::BadMagic { .. })
1712 ));
1713
1714 let futv = td.path().join("futv.tar");
1716 {
1717 let mut b = tar::Builder::new(std::fs::File::create(&futv).unwrap());
1718 let m = Manifest {
1719 magic: BUNDLE_MAGIC.to_string(),
1720 format_version: BUNDLE_FORMAT_VERSION + 99,
1721 created_at: 0,
1722 data_only: true,
1723 source: SourceInfo {
1724 project: "x".to_string(),
1725 site: None,
1726 },
1727 members: vec![],
1728 };
1729 let j = serde_json::to_vec(&m).unwrap();
1730 append_bytes(&mut b, MANIFEST_PATH, 0, &j).unwrap();
1731 b.finish().unwrap();
1732 }
1733 assert!(matches!(
1734 read_manifest(&futv),
1735 Err(BundleError::BadVersion { .. })
1736 ));
1737 }
1738
1739 #[test]
1740 fn extract_catches_a_checksum_mismatch() {
1741 let td = tempfile::tempdir().unwrap();
1744 let bad = td.path().join("tamper.tar");
1745 {
1746 let mut b = tar::Builder::new(std::fs::File::create(&bad).unwrap());
1747 let m = Manifest {
1748 magic: BUNDLE_MAGIC.to_string(),
1749 format_version: BUNDLE_FORMAT_VERSION,
1750 created_at: 0,
1751 data_only: true,
1752 source: SourceInfo {
1753 project: "x".to_string(),
1754 site: None,
1755 },
1756 members: vec![ManifestMember {
1757 path: "sql/app.pgc".to_string(),
1758 kind: MemberKind::PgDump,
1759 size: 3,
1760 sha256: "00".repeat(32), pg: None,
1762 sql_cursor: None,
1763 }],
1764 };
1765 let j = serde_json::to_vec(&m).unwrap();
1766 append_bytes(&mut b, MANIFEST_PATH, 0, &j).unwrap();
1767 append_bytes(&mut b, "sql/app.pgc", 0, b"abc").unwrap();
1768 b.finish().unwrap();
1769 }
1770 let out = td.path().join("x");
1771 assert!(matches!(
1772 extract_and_verify(&bad, &out),
1773 Err(BundleError::ChecksumMismatch { .. })
1774 ));
1775 }
1776
1777 #[test]
1778 fn rejects_unsafe_member_paths() {
1779 assert!(validate_member_path("sql/app.pgc").is_ok());
1780 assert!(validate_member_path("libsql/acme/site/db.sqlite").is_ok());
1781 assert!(validate_member_path("/etc/passwd").is_err());
1782 assert!(validate_member_path("../escape").is_err());
1783 assert!(validate_member_path("a/../b").is_err());
1784 assert!(validate_member_path("").is_err());
1785 }
1786
1787 #[test]
1790 fn v2_round_trips_streaming_write_then_single_pass_read() {
1791 let td = tempfile::tempdir().unwrap();
1792 let stage_dir = td.path().join("stage");
1793 std::fs::create_dir_all(&stage_dir).unwrap();
1794
1795 let members: Vec<(MemberSpec, Vec<u8>)> = vec![
1797 (
1798 MemberSpec {
1799 path: IDENTITY_MEMBER_PATH.to_string(),
1800 kind: MemberKind::Identity,
1801 framing: MemberFraming::Sidecar,
1802 pg: None,
1803 sql_cursor: None,
1804 },
1805 b"IDENTITY-KEK-TRANSPORT".to_vec(),
1806 ),
1807 (
1808 MemberSpec {
1809 path: "blobs/acme/obj1".to_string(),
1810 kind: MemberKind::Blob,
1811 framing: MemberFraming::Sidecar,
1812 pg: None,
1813 sql_cursor: None,
1814 },
1815 vec![0xAB; 4096],
1816 ),
1817 (
1818 MemberSpec {
1819 path: "sql/app.pgc".to_string(),
1820 kind: MemberKind::PgDump,
1821 framing: MemberFraming::Sidecar,
1822 pg: Some(PgMemberInfo {
1823 server_version_num: "160004".to_string(),
1824 client_image: "pgvector/pgvector:pg16".to_string(),
1825 schema_fingerprint: Some("deadbeef".to_string()),
1826 }),
1827 sql_cursor: None,
1828 },
1829 vec![7u8; 50_000],
1830 ),
1831 (
1832 MemberSpec {
1833 path: KV_MEMBER_PATH.to_string(),
1834 kind: MemberKind::Kv,
1835 framing: MemberFraming::Sidecar,
1836 pg: None,
1837 sql_cursor: None,
1838 },
1839 b"BRKVDUMP\x01\x02\x03".to_vec(),
1840 ),
1841 ];
1842 let specs: Vec<MemberSpec> = members.iter().map(|(s, _)| s.clone()).collect();
1843 let bytes_by_path: std::collections::HashMap<String, Vec<u8>> = members
1844 .iter()
1845 .map(|(s, b)| (s.path.clone(), b.clone()))
1846 .collect();
1847
1848 let mut bundle: Vec<u8> = Vec::new();
1851 let mut idx = 0u32;
1852 write_bundle_streaming(
1853 &mut bundle,
1854 1_700_000_000,
1855 false,
1856 SourceInfo {
1857 project: "acme".to_string(),
1858 site: Some("prod".to_string()),
1859 },
1860 specs.clone(),
1861 |spec| {
1862 let data = bytes_by_path.get(&spec.path).expect("a known member");
1863 let tmp = stage_dir.join(format!("m{idx}"));
1864 idx += 1;
1865 std::fs::write(&tmp, data).unwrap();
1866 Ok(tmp)
1867 },
1868 )
1869 .unwrap();
1870
1871 let mut got: Vec<(MemberSpec, Vec<u8>)> = Vec::new();
1873 let restored = read_bundle_streaming(bundle.as_slice(), |spec, size, body| {
1874 let mut v = Vec::new();
1875 body.read_to_end(&mut v).map_err(BundleError::Io)?;
1876 assert_eq!(v.len() as u64, size, "declared size == streamed bytes");
1877 got.push((spec.clone(), v));
1878 Ok(())
1879 })
1880 .unwrap();
1881
1882 assert_eq!(restored, specs, "members restored in the declared order");
1883 assert_eq!(got.len(), members.len());
1884 for ((ws, wb), (gs, gb)) in members.iter().zip(&got) {
1885 assert_eq!(ws, gs);
1886 assert_eq!(wb, gb, "member {:?} bytes round-trip", ws.path);
1887 }
1888 let pg = restored
1890 .iter()
1891 .find(|m| m.kind == MemberKind::PgDump)
1892 .unwrap();
1893 assert_eq!(
1894 pg.pg.as_ref().unwrap().client_image,
1895 "pgvector/pgvector:pg16"
1896 );
1897 }
1898
1899 #[test]
1900 fn read_bundle_streaming_reads_a_v1_bundle() {
1901 let td = tempfile::tempdir().unwrap();
1904 let stage = td.path().join("stage");
1905 std::fs::create_dir_all(&stage).unwrap();
1906 let kv = write_staging(&stage, "kv.bin", b"BRKVDUMP\x01\x02\x03");
1907 let pg = write_staging(&stage, "app.pgc", &vec![7u8; 5000]);
1908 let bundle = td.path().join("v1.brproj");
1909 let staged = vec![
1910 StagedMember {
1911 path: KV_MEMBER_PATH.to_string(),
1912 kind: MemberKind::Kv,
1913 source: kv,
1914 pg: None,
1915 },
1916 StagedMember {
1917 path: "sql/app.pgc".to_string(),
1918 kind: MemberKind::PgDump,
1919 source: pg,
1920 pg: Some(PgMemberInfo {
1921 server_version_num: "160004".to_string(),
1922 client_image: "pgvector/pgvector:pg16".to_string(),
1923 schema_fingerprint: None,
1924 }),
1925 },
1926 ];
1927 write_bundle(
1928 &bundle,
1929 1_700_000_000,
1930 true,
1931 SourceInfo {
1932 project: "acme".to_string(),
1933 site: None,
1934 },
1935 &staged,
1936 )
1937 .unwrap();
1938
1939 let file = std::fs::File::open(&bundle).unwrap();
1940 let mut got: std::collections::HashMap<String, Vec<u8>> = std::collections::HashMap::new();
1941 let restored = read_bundle_streaming(io::BufReader::new(file), |spec, _size, body| {
1942 let mut v = Vec::new();
1943 body.read_to_end(&mut v).map_err(BundleError::Io)?;
1944 got.insert(spec.path.clone(), v);
1945 Ok(())
1946 })
1947 .unwrap();
1948 assert_eq!(restored.len(), 2);
1949 assert_eq!(got.get(KV_MEMBER_PATH).unwrap(), b"BRKVDUMP\x01\x02\x03");
1950 assert_eq!(got.get("sql/app.pgc").unwrap(), &vec![7u8; 5000]);
1951 }
1952
1953 #[test]
1954 fn read_bundle_streaming_catches_a_tampered_member() {
1955 let mut buf: Vec<u8> = Vec::new();
1958 {
1959 let mut b = tar::Builder::new(&mut buf);
1960 let manifest = StreamManifest {
1961 magic: BUNDLE_MAGIC.to_string(),
1962 format_version: BUNDLE_FORMAT_VERSION_V2,
1963 created_at: 0,
1964 data_only: true,
1965 source: SourceInfo {
1966 project: "x".to_string(),
1967 site: None,
1968 },
1969 members: vec![MemberSpec {
1970 path: "sql/app.pgc".to_string(),
1971 kind: MemberKind::PgDump,
1972 framing: MemberFraming::Sidecar,
1973 pg: None,
1974 sql_cursor: None,
1975 }],
1976 delta: None,
1977 };
1978 append_bytes(
1979 &mut b,
1980 MANIFEST_PATH,
1981 0,
1982 &serde_json::to_vec(&manifest).unwrap(),
1983 )
1984 .unwrap();
1985 let meta = MemberMeta {
1986 size: 3,
1987 sha256: "00".repeat(32), };
1989 append_bytes(
1990 &mut b,
1991 "sql/app.pgc.brmeta",
1992 0,
1993 &serde_json::to_vec(&meta).unwrap(),
1994 )
1995 .unwrap();
1996 append_bytes(&mut b, "sql/app.pgc", 0, b"abc").unwrap();
1997 b.finish().unwrap();
1998 }
1999 let r = read_bundle_streaming(buf.as_slice(), |_s, _sz, body| {
2000 let mut v = Vec::new();
2001 body.read_to_end(&mut v).map_err(BundleError::Io)?;
2002 Ok(())
2003 });
2004 assert!(
2005 matches!(r, Err(BundleError::ChecksumMismatch { .. })),
2006 "got {r:?}"
2007 );
2008 }
2009
2010 #[test]
2011 fn read_bundle_streaming_rejects_a_member_without_its_sidecar() {
2012 let mut buf: Vec<u8> = Vec::new();
2013 {
2014 let mut b = tar::Builder::new(&mut buf);
2015 let manifest = StreamManifest {
2016 magic: BUNDLE_MAGIC.to_string(),
2017 format_version: BUNDLE_FORMAT_VERSION_V2,
2018 created_at: 0,
2019 data_only: true,
2020 source: SourceInfo {
2021 project: "x".to_string(),
2022 site: None,
2023 },
2024 members: vec![MemberSpec {
2025 path: "sql/app.pgc".to_string(),
2026 kind: MemberKind::PgDump,
2027 framing: MemberFraming::Sidecar,
2028 pg: None,
2029 sql_cursor: None,
2030 }],
2031 delta: None,
2032 };
2033 append_bytes(
2034 &mut b,
2035 MANIFEST_PATH,
2036 0,
2037 &serde_json::to_vec(&manifest).unwrap(),
2038 )
2039 .unwrap();
2040 append_bytes(&mut b, "sql/app.pgc", 0, b"abc").unwrap();
2042 b.finish().unwrap();
2043 }
2044 let r = read_bundle_streaming(buf.as_slice(), |_s, _sz, _b| Ok(()));
2045 assert!(
2046 matches!(r, Err(BundleError::MalformedStream(_))),
2047 "got {r:?}"
2048 );
2049 }
2050
2051 #[test]
2052 fn write_bundle_streaming_refuses_an_out_of_order_or_missing_member() {
2053 let specs = vec![
2055 MemberSpec {
2056 path: "a".to_string(),
2057 kind: MemberKind::Blob,
2058 framing: MemberFraming::Sidecar,
2059 pg: None,
2060 sql_cursor: None,
2061 },
2062 MemberSpec {
2063 path: "b".to_string(),
2064 kind: MemberKind::Blob,
2065 framing: MemberFraming::Sidecar,
2066 pg: None,
2067 sql_cursor: None,
2068 },
2069 ];
2070 let td = tempfile::tempdir().unwrap();
2071 let tmp = td.path().join("x");
2072 std::fs::write(&tmp, b"hello").unwrap();
2073
2074 let mut sink: Vec<u8> = Vec::new();
2076 let mut w = BundleStreamWriter::new(
2077 &mut sink,
2078 0,
2079 true,
2080 SourceInfo {
2081 project: "x".to_string(),
2082 site: None,
2083 },
2084 specs.clone(),
2085 )
2086 .unwrap();
2087 assert!(matches!(
2088 w.append_member("b", MemberKind::Blob, &tmp),
2089 Err(BundleError::MalformedStream(_))
2090 ));
2091
2092 let mut sink2: Vec<u8> = Vec::new();
2094 let mut w2 = BundleStreamWriter::new(
2095 &mut sink2,
2096 0,
2097 true,
2098 SourceInfo {
2099 project: "x".to_string(),
2100 site: None,
2101 },
2102 specs,
2103 )
2104 .unwrap();
2105 w2.append_member("a", MemberKind::Blob, &tmp).unwrap();
2106 assert!(matches!(w2.finish(), Err(BundleError::MissingMember(_))));
2107 }
2108
2109 fn staged_bytes(dir: &Path) -> u64 {
2111 std::fs::read_dir(dir)
2112 .unwrap()
2113 .filter_map(|e| e.ok())
2114 .filter_map(|e| e.metadata().ok())
2115 .filter(|m| m.is_file())
2116 .map(|m| m.len())
2117 .sum()
2118 }
2119
2120 #[test]
2121 fn write_bundle_streaming_bounds_staging_to_the_largest_member() {
2122 let td = tempfile::tempdir().unwrap();
2126 let stage_dir = td.path().join("stage");
2127 std::fs::create_dir_all(&stage_dir).unwrap();
2128
2129 const MEMBER: usize = 256 * 1024;
2130 const N: usize = 6;
2131 let largest = MEMBER as u64;
2132 let whole = (MEMBER * N) as u64;
2133
2134 let specs: Vec<MemberSpec> = (0..N)
2135 .map(|i| MemberSpec {
2136 path: format!("blobs/acme/obj{i}"),
2137 kind: MemberKind::Blob,
2138 framing: MemberFraming::Sidecar,
2139 pg: None,
2140 sql_cursor: None,
2141 })
2142 .collect();
2143
2144 let mut peak: u64 = 0;
2146 let mut bundle: Vec<u8> = Vec::new();
2147 write_bundle_streaming(
2148 &mut bundle,
2149 0,
2150 false,
2151 SourceInfo {
2152 project: "acme".to_string(),
2153 site: None,
2154 },
2155 specs.clone(),
2156 |spec| {
2157 let tmp = stage_dir.join(spec.path.rsplit('/').next().unwrap());
2158 std::fs::write(&tmp, vec![0u8; MEMBER]).unwrap();
2159 peak = peak.max(staged_bytes(&stage_dir)); Ok(tmp)
2161 },
2162 )
2163 .unwrap();
2164
2165 assert!(
2168 peak <= largest + 4096,
2169 "streaming staging peak {peak} must stay ≈ one member ({largest}), \
2170 far below the whole bundle ({whole})"
2171 );
2172 assert!(
2173 peak < whole / 2,
2174 "peak {peak} must be far below whole-bundle staging {whole}"
2175 );
2176
2177 for f in std::fs::read_dir(&stage_dir).unwrap() {
2181 let _ = std::fs::remove_file(f.unwrap().path());
2182 }
2183 let mut mutant_peak: u64 = 0;
2184 for i in 0..N {
2185 let tmp = stage_dir.join(format!("all{i}"));
2186 std::fs::write(&tmp, vec![0u8; MEMBER]).unwrap();
2187 mutant_peak = mutant_peak.max(staged_bytes(&stage_dir));
2188 }
2189 assert_eq!(
2190 mutant_peak, whole,
2191 "whole-bundle-staging mutation drives the metric to the full bundle"
2192 );
2193 assert!(
2194 mutant_peak > largest + 4096,
2195 "so the bounded-staging assertion above is genuinely RED under that mutation"
2196 );
2197 }
2198
2199 #[test]
2200 fn read_bundle_streaming_staged_verifies_before_apply() {
2201 use std::sync::atomic::{AtomicUsize, Ordering};
2202
2203 let mut buf: Vec<u8> = Vec::new();
2207 {
2208 let mut b = tar::Builder::new(&mut buf);
2209 let manifest = StreamManifest {
2210 magic: BUNDLE_MAGIC.to_string(),
2211 format_version: BUNDLE_FORMAT_VERSION_V2,
2212 created_at: 0,
2213 data_only: true,
2214 source: SourceInfo {
2215 project: "x".to_string(),
2216 site: None,
2217 },
2218 members: vec![MemberSpec {
2219 path: "sql/app.pgc".to_string(),
2220 kind: MemberKind::PgDump,
2221 framing: MemberFraming::Sidecar,
2222 pg: None,
2223 sql_cursor: None,
2224 }],
2225 delta: None,
2226 };
2227 append_bytes(
2228 &mut b,
2229 MANIFEST_PATH,
2230 0,
2231 &serde_json::to_vec(&manifest).unwrap(),
2232 )
2233 .unwrap();
2234 let meta = MemberMeta {
2235 size: 3,
2236 sha256: "00".repeat(32), };
2238 append_bytes(
2239 &mut b,
2240 "sql/app.pgc.brmeta",
2241 0,
2242 &serde_json::to_vec(&meta).unwrap(),
2243 )
2244 .unwrap();
2245 append_bytes(&mut b, "sql/app.pgc", 0, b"abc").unwrap();
2246 b.finish().unwrap();
2247 }
2248 let td = tempfile::tempdir().unwrap();
2249 let applied = AtomicUsize::new(0);
2250 let r = read_bundle_streaming_staged(
2251 buf.as_slice(),
2252 td.path(),
2253 |_d, _s| {},
2254 |_spec, _temp| {
2255 applied.fetch_add(1, Ordering::SeqCst);
2256 Ok(())
2257 },
2258 );
2259 assert!(
2260 matches!(r, Err(BundleError::ChecksumMismatch { .. })),
2261 "got {r:?}"
2262 );
2263 assert_eq!(
2264 applied.load(Ordering::SeqCst),
2265 0,
2266 "restore (apply) must NOT run for a member that fails verification"
2267 );
2268
2269 let stage = td.path().join("gen");
2271 std::fs::create_dir_all(&stage).unwrap();
2272 let mut good: Vec<u8> = Vec::new();
2273 write_bundle_streaming(
2274 &mut good,
2275 0,
2276 true,
2277 SourceInfo {
2278 project: "x".to_string(),
2279 site: None,
2280 },
2281 vec![MemberSpec {
2282 path: KV_MEMBER_PATH.to_string(),
2283 kind: MemberKind::Kv,
2284 framing: MemberFraming::Sidecar,
2285 pg: None,
2286 sql_cursor: None,
2287 }],
2288 |_s| {
2289 let p = stage.join("m");
2290 std::fs::write(&p, b"BRKVDUMP-OK").unwrap();
2291 Ok(p)
2292 },
2293 )
2294 .unwrap();
2295 let seen = std::cell::RefCell::new(Vec::new());
2296 read_bundle_streaming_staged(
2297 good.as_slice(),
2298 td.path(),
2299 |_d, _s| {},
2300 |spec, temp| {
2301 seen.borrow_mut()
2302 .push((spec.path.clone(), std::fs::read(temp).unwrap()));
2303 Ok(())
2304 },
2305 )
2306 .unwrap();
2307 assert_eq!(
2308 seen.into_inner(),
2309 vec![("kv.brkvdump".to_string(), b"BRKVDUMP-OK".to_vec())],
2310 "a verified member is applied from its staged temp with the exact bytes"
2311 );
2312 }
2313
2314 #[derive(Default)]
2321 struct RecordingRestorer {
2322 sidecars: Vec<(MemberSpec, Vec<u8>)>,
2323 begins: Vec<String>,
2324 finishes: Vec<(String, bool)>,
2325 cur: Option<Vec<u8>>,
2327 committed_chunked: Vec<(MemberSpec, Vec<u8>)>,
2329 max_write_buf: usize,
2332 }
2333
2334 impl BundleRestorer for RecordingRestorer {
2335 fn restore_sidecar(&mut self, spec: &MemberSpec, temp: &Path) -> Result<(), BundleError> {
2336 self.sidecars.push((spec.clone(), std::fs::read(temp)?));
2337 Ok(())
2338 }
2339 fn begin_chunked(&mut self, spec: &MemberSpec) -> Result<(), BundleError> {
2340 self.begins.push(spec.path.clone());
2341 self.cur = Some(Vec::new());
2342 Ok(())
2343 }
2344 fn write_chunk(&mut self, _spec: &MemberSpec, buf: &[u8]) -> Result<(), BundleError> {
2345 self.max_write_buf = self.max_write_buf.max(buf.len());
2346 self.cur
2347 .as_mut()
2348 .expect("write_chunk between begin and finish")
2349 .extend_from_slice(buf);
2350 Ok(())
2351 }
2352 fn finish_chunked(&mut self, spec: &MemberSpec, verified: bool) -> Result<(), BundleError> {
2353 self.finishes.push((spec.path.clone(), verified));
2354 let bytes = self.cur.take().unwrap_or_default();
2355 if verified {
2356 self.committed_chunked.push((spec.clone(), bytes));
2357 }
2358 Ok(())
2359 }
2360 }
2361
2362 fn count_parts(bundle: &[u8], base: &str) -> (usize, u64) {
2366 let mut ar = tar::Archive::new(bundle);
2367 let mut n = 0usize;
2368 let mut max = 0u64;
2369 for e in ar.entries().unwrap() {
2370 let e = e.unwrap();
2371 let p = e.path().unwrap().to_string_lossy().into_owned();
2372 if p.starts_with(&format!("{base}{PART_INFIX}")) {
2373 n += 1;
2374 max = max.max(e.header().size().unwrap());
2375 }
2376 }
2377 (n, max)
2378 }
2379
2380 #[test]
2381 fn v3_chunked_round_trips_with_bounded_part_staging() {
2382 const CHUNK: usize = 4096;
2386 let body: Vec<u8> = (0..CHUNK * 5).map(|i| (i % 251) as u8).collect();
2387 let specs = vec![
2388 MemberSpec::sidecar(KV_MEMBER_PATH.to_string(), MemberKind::Kv, None),
2389 MemberSpec::chunked(
2390 "libsql/acme/blog/default.sqlite".to_string(),
2391 MemberKind::Libsql,
2392 None,
2393 ),
2394 ];
2395 let td = tempfile::tempdir().unwrap();
2396 let kv_tmp = td.path().join("kv");
2397 std::fs::write(&kv_tmp, b"BRKVDUMP-KV").unwrap();
2398
2399 let body_for_open = body.clone();
2400 let mut bundle: Vec<u8> = Vec::new();
2401 write_bundle_streaming_v3(
2402 &mut bundle,
2403 1_700_000_000,
2404 false,
2405 SourceInfo {
2406 project: "acme".to_string(),
2407 site: None,
2408 },
2409 specs.clone(),
2410 CHUNK,
2411 |_spec| Ok(kv_tmp.clone()),
2412 |_spec| Ok(Box::new(io::Cursor::new(body_for_open.clone())) as Box<dyn Read>),
2413 )
2414 .unwrap();
2415
2416 assert_eq!(read_manifest_version(&bundle), BUNDLE_FORMAT_VERSION_V3);
2418 let (parts, max_part) = count_parts(&bundle, "libsql/acme/blog/default.sqlite");
2420 assert_eq!(parts, 5, "a 5×chunk body is written as 5 `.part` entries");
2421 assert!(
2422 max_part <= CHUNK as u64,
2423 "no part exceeds the chunk size (never a whole-member entry): {max_part}"
2424 );
2425
2426 let stage = td.path().join("stage");
2428 std::fs::create_dir_all(&stage).unwrap();
2429 let mut r = RecordingRestorer::default();
2430 let mut data_only = true;
2431 let restored =
2432 read_bundle_streaming_atomic(bundle.as_slice(), &stage, |d, _s| data_only = d, &mut r)
2433 .unwrap();
2434
2435 assert!(!data_only);
2436 assert_eq!(restored, specs, "both members restored in declared order");
2437 assert_eq!(r.sidecars.len(), 1);
2439 assert_eq!(r.sidecars[0].1, b"BRKVDUMP-KV");
2440 assert_eq!(
2441 r.begins,
2442 vec!["libsql/acme/blog/default.sqlite".to_string()]
2443 );
2444 assert_eq!(
2445 r.finishes,
2446 vec![("libsql/acme/blog/default.sqlite".to_string(), true)],
2447 "finish_chunked fired ONCE with verified=true (commit)"
2448 );
2449 assert_eq!(r.committed_chunked.len(), 1);
2450 assert_eq!(
2451 r.committed_chunked[0].1, body,
2452 "the chunked member round-trips byte-for-byte"
2453 );
2454 assert!(
2456 r.max_write_buf <= 64 * 1024,
2457 "reader streams in ≤64 KiB reads, never the whole member: {}",
2458 r.max_write_buf
2459 );
2460
2461 }
2464
2465 #[test]
2466 fn v3_chunked_trailer_mismatch_aborts_and_never_commits() {
2467 let base = "libsql/acme/blog/default.sqlite";
2470 let mut buf: Vec<u8> = Vec::new();
2471 {
2472 let mut b = tar::Builder::new(&mut buf);
2473 let manifest = StreamManifest {
2474 magic: BUNDLE_MAGIC.to_string(),
2475 format_version: BUNDLE_FORMAT_VERSION_V3,
2476 created_at: 0,
2477 data_only: true,
2478 source: SourceInfo {
2479 project: "acme".to_string(),
2480 site: None,
2481 },
2482 members: vec![MemberSpec::chunked(
2483 base.to_string(),
2484 MemberKind::Libsql,
2485 None,
2486 )],
2487 delta: None,
2488 };
2489 append_bytes(
2490 &mut b,
2491 MANIFEST_PATH,
2492 0,
2493 &serde_json::to_vec(&manifest).unwrap(),
2494 )
2495 .unwrap();
2496 append_bytes(
2497 &mut b,
2498 &chunk_part_path(base, 0),
2499 0,
2500 b"the real member bytes",
2501 )
2502 .unwrap();
2503 let trailer = MemberTrailer {
2504 size: 21,
2505 sha256: "00".repeat(32), };
2507 append_bytes(
2508 &mut b,
2509 &chunk_trailer_path(base),
2510 0,
2511 &serde_json::to_vec(&trailer).unwrap(),
2512 )
2513 .unwrap();
2514 b.finish().unwrap();
2515 }
2516 let td = tempfile::tempdir().unwrap();
2517 let mut r = RecordingRestorer::default();
2518 let res = read_bundle_streaming_atomic(buf.as_slice(), td.path(), |_d, _s| {}, &mut r);
2519 assert!(
2520 matches!(res, Err(BundleError::ChecksumMismatch { .. })),
2521 "a lying trailer is a loud reject: {res:?}"
2522 );
2523 assert_eq!(
2524 r.finishes,
2525 vec![(base.to_string(), false)],
2526 "finish_chunked fired with verified=false (DROP the shadow); commit NEVER fired"
2527 );
2528 assert!(
2529 r.committed_chunked.is_empty(),
2530 "no chunked member is committed when the trailer mismatches"
2531 );
2532 }
2533
2534 #[test]
2535 fn v3_chunked_truncation_before_trailer_aborts() {
2536 let base = "libsql/acme/blog/default.sqlite";
2539 let mut buf: Vec<u8> = Vec::new();
2540 {
2541 let mut b = tar::Builder::new(&mut buf);
2542 let manifest = StreamManifest {
2543 magic: BUNDLE_MAGIC.to_string(),
2544 format_version: BUNDLE_FORMAT_VERSION_V3,
2545 created_at: 0,
2546 data_only: true,
2547 source: SourceInfo {
2548 project: "acme".to_string(),
2549 site: None,
2550 },
2551 members: vec![MemberSpec::chunked(
2552 base.to_string(),
2553 MemberKind::Libsql,
2554 None,
2555 )],
2556 delta: None,
2557 };
2558 append_bytes(
2559 &mut b,
2560 MANIFEST_PATH,
2561 0,
2562 &serde_json::to_vec(&manifest).unwrap(),
2563 )
2564 .unwrap();
2565 append_bytes(
2566 &mut b,
2567 &chunk_part_path(base, 0),
2568 0,
2569 b"a chunk but no trailer",
2570 )
2571 .unwrap();
2572 b.finish().unwrap(); }
2574 let td = tempfile::tempdir().unwrap();
2575 let mut r = RecordingRestorer::default();
2576 let res = read_bundle_streaming_atomic(buf.as_slice(), td.path(), |_d, _s| {}, &mut r);
2577 assert!(
2578 matches!(res, Err(BundleError::MalformedStream(_))),
2579 "a truncated chunked member (no trailer) is rejected: {res:?}"
2580 );
2581 assert_eq!(
2582 r.finishes,
2583 vec![(base.to_string(), false)],
2584 "sink DROPPED on truncation"
2585 );
2586 assert!(r.committed_chunked.is_empty());
2587 }
2588
2589 #[test]
2590 fn atomic_reader_reads_v1_and_v2_but_an_inc1_node_loud_rejects_v3() {
2591 let td = tempfile::tempdir().unwrap();
2593 let stage = td.path().join("stage");
2594 std::fs::create_dir_all(&stage).unwrap();
2595
2596 let kv = write_staging(&stage, "kv.bin", b"V1-KV");
2598 let v1 = td.path().join("v1.brproj");
2599 write_bundle(
2600 &v1,
2601 0,
2602 true,
2603 SourceInfo {
2604 project: "acme".to_string(),
2605 site: None,
2606 },
2607 &[StagedMember {
2608 path: KV_MEMBER_PATH.to_string(),
2609 kind: MemberKind::Kv,
2610 source: kv,
2611 pg: None,
2612 }],
2613 )
2614 .unwrap();
2615 let mut r1 = RecordingRestorer::default();
2616 read_bundle_streaming_atomic(
2617 io::BufReader::new(std::fs::File::open(&v1).unwrap()),
2618 &stage,
2619 |_d, _s| {},
2620 &mut r1,
2621 )
2622 .unwrap();
2623 assert_eq!(r1.sidecars.len(), 1);
2624 assert_eq!(r1.sidecars[0].1, b"V1-KV");
2625
2626 let gendir = td.path().join("gen");
2628 std::fs::create_dir_all(&gendir).unwrap();
2629 let mut v2: Vec<u8> = Vec::new();
2630 write_bundle_streaming(
2631 &mut v2,
2632 0,
2633 true,
2634 SourceInfo {
2635 project: "acme".to_string(),
2636 site: None,
2637 },
2638 vec![MemberSpec::sidecar(
2639 KV_MEMBER_PATH.to_string(),
2640 MemberKind::Kv,
2641 None,
2642 )],
2643 |_s| {
2644 let p = gendir.join("m");
2645 std::fs::write(&p, b"V2-KV").unwrap();
2646 Ok(p)
2647 },
2648 )
2649 .unwrap();
2650 assert_eq!(read_manifest_version(&v2), BUNDLE_FORMAT_VERSION_V2);
2651 let mut r2 = RecordingRestorer::default();
2652 read_bundle_streaming_atomic(v2.as_slice(), &stage, |_d, _s| {}, &mut r2).unwrap();
2653 assert_eq!(r2.sidecars[0].1, b"V2-KV");
2654
2655 let mut v3: Vec<u8> = Vec::new();
2658 write_bundle_streaming_v3(
2659 &mut v3,
2660 0,
2661 true,
2662 SourceInfo {
2663 project: "acme".to_string(),
2664 site: None,
2665 },
2666 vec![MemberSpec::chunked(
2667 "libsql/acme/blog/default.sqlite".to_string(),
2668 MemberKind::Libsql,
2669 None,
2670 )],
2671 16,
2672 |_s| unreachable!("no sidecar member"),
2673 |_s| Ok(Box::new(io::Cursor::new(b"hello v3 chunked".to_vec())) as Box<dyn Read>),
2674 )
2675 .unwrap();
2676 assert_eq!(read_manifest_version(&v3), BUNDLE_FORMAT_VERSION_V3);
2677 let legacy = read_bundle_streaming(v3.as_slice(), |_s, _sz, _b| Ok(()));
2678 assert!(
2679 matches!(legacy, Err(BundleError::BadVersion { found: 3, .. })),
2680 "the legacy v1/v2 reader loud-rejects a v3 bundle: {legacy:?}"
2681 );
2682 }
2683
2684 fn read_manifest_version(bundle: &[u8]) -> u32 {
2686 let mut ar = tar::Archive::new(bundle);
2687 let mut first = ar.entries().unwrap().next().unwrap().unwrap();
2688 let mut s = String::new();
2689 first.read_to_string(&mut s).unwrap();
2690 peek_format_version(&s).unwrap()
2691 }
2692}