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 IDENTITY_MEMBER_PATH: &str = "identity/identity.brid";
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "snake_case")]
55pub enum MemberKind {
56 PgDump,
58 MysqlDump,
60 Libsql,
62 Kv,
64 Blob,
66 Identity,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct PgMemberInfo {
78 pub server_version_num: String,
81 pub client_image: String,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub schema_fingerprint: Option<String>,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94pub struct ManifestMember {
95 pub path: String,
97 pub kind: MemberKind,
99 pub size: u64,
101 pub sha256: String,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub pg: Option<PgMemberInfo>,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct SourceInfo {
111 pub project: String,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub site: Option<String>,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120pub struct Manifest {
121 pub magic: String,
123 pub format_version: u32,
125 pub created_at: u64,
127 pub data_only: bool,
129 pub source: SourceInfo,
131 pub members: Vec<ManifestMember>,
133}
134
135#[derive(Debug, thiserror::Error)]
137pub enum BundleError {
138 #[error("bundle io: {0}")]
140 Io(#[from] io::Error),
141 #[error("bundle manifest json: {0}")]
143 Json(#[from] serde_json::Error),
144 #[error("not a boatramp project bundle: expected magic {expected:?}, found {found:?}")]
146 BadMagic {
147 expected: String,
149 found: String,
151 },
152 #[error("unsupported bundle format version {found} (this build reads {supported})")]
154 BadVersion {
155 found: u32,
157 supported: u32,
159 },
160 #[error("bundle is missing its {0:?} entry as the first entry")]
162 MissingManifest(String),
163 #[error("checksum MISMATCH for member {path:?}: manifest {expected}, extracted {actual}")]
165 ChecksumMismatch {
166 path: String,
168 expected: String,
170 actual: String,
172 },
173 #[error("member {0:?} named in the manifest is absent from the bundle")]
175 MissingMember(String),
176 #[error("member {0:?} is present in the bundle but absent from the manifest")]
178 UnexpectedMember(String),
179 #[error("unsafe member path {0:?} in bundle (absolute or traversal)")]
181 UnsafePath(String),
182 #[error("malformed v2 bundle stream: {0}")]
185 MalformedStream(String),
186 #[error("size MISMATCH for member {path:?}: declared {expected}, streamed {actual}")]
188 SizeMismatch {
189 path: String,
191 expected: u64,
193 actual: u64,
195 },
196}
197
198impl Manifest {
199 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 pub fn pg_members(&self) -> impl Iterator<Item = &ManifestMember> {
219 self.members.iter().filter(|m| m.kind == MemberKind::PgDump)
220 }
221}
222
223#[derive(Debug, Clone)]
225pub struct StagedMember {
226 pub path: String,
228 pub kind: MemberKind,
230 pub source: PathBuf,
232 pub pg: Option<PgMemberInfo>,
234}
235
236fn 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
255fn 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
271fn 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 builder.append_data(&mut header, path, data)?;
284 Ok(())
285}
286
287pub 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 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()?; 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
340pub 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
361pub 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 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
417pub const BUNDLE_FORMAT_VERSION_V2: u32 = 2;
440
441pub const META_SUFFIX: &str = ".brmeta";
445
446pub const BUNDLE_FORMAT_VERSION_V3: u32 = 3;
472
473pub const TRAILER_SUFFIX: &str = ".brtrailer";
478
479pub const PART_INFIX: &str = ".part";
482
483fn chunk_part_path(base: &str, n: u32) -> String {
485 format!("{base}{PART_INFIX}{n:05}")
486}
487
488fn chunk_trailer_path(base: &str) -> String {
490 format!("{base}{TRAILER_SUFFIX}")
491}
492
493#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
497#[serde(rename_all = "snake_case")]
498pub enum MemberFraming {
499 #[default]
502 Sidecar,
503 ChunkedTrailer,
506}
507
508impl MemberFraming {
509 fn is_sidecar(&self) -> bool {
512 matches!(self, MemberFraming::Sidecar)
513 }
514}
515
516#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
520pub struct MemberSpec {
521 pub path: String,
523 pub kind: MemberKind,
525 #[serde(default, skip_serializing_if = "MemberFraming::is_sidecar")]
530 pub framing: MemberFraming,
531 #[serde(default, skip_serializing_if = "Option::is_none")]
533 pub pg: Option<PgMemberInfo>,
534}
535
536impl MemberSpec {
537 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
562pub struct MemberTrailer {
563 pub size: u64,
565 pub sha256: String,
567}
568
569#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
573pub struct StreamManifest {
574 pub magic: String,
576 pub format_version: u32,
578 pub created_at: u64,
580 pub data_only: bool,
582 pub source: SourceInfo,
584 pub members: Vec<MemberSpec>,
586}
587
588impl StreamManifest {
589 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 fn meta_path(path: &str) -> String {
613 format!("{path}{META_SUFFIX}")
614 }
615}
616
617#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
620pub struct MemberMeta {
621 pub size: u64,
623 pub sha256: String,
625}
626
627pub struct BundleStreamWriter<W: Write> {
635 builder: tar::Builder<W>,
636 created_at: u64,
637 remaining: std::collections::VecDeque<MemberSpec>,
640}
641
642impl<W: Write> BundleStreamWriter<W> {
643 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 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 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 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 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 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 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 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; }
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; }
805 }
806
807 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 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
833pub 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 let _ = std::fs::remove_file(&tmp);
862 appended?;
863 }
864 writer.finish()
865}
866
867#[allow(clippy::too_many_arguments)] pub 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
910struct 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
944fn 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
963fn 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 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
1000fn 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 let r = restore(spec, &temp);
1055 let _ = std::fs::remove_file(&temp);
1056 r
1057}
1058
1059pub 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
1079pub 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
1101pub 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
1129fn 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 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 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 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 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 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
1247pub trait BundleRestorer {
1261 fn restore_sidecar(&mut self, spec: &MemberSpec, temp: &Path) -> Result<(), BundleError>;
1264 fn begin_chunked(&mut self, spec: &MemberSpec) -> Result<(), BundleError>;
1267 fn write_chunk(&mut self, spec: &MemberSpec, buf: &[u8]) -> Result<(), BundleError>;
1270 fn finish_chunked(&mut self, spec: &MemberSpec, verified: bool) -> Result<(), BundleError>;
1273}
1274
1275pub 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 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 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 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 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 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
1421fn 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 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, };
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 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 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 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 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 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 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 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 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 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), 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 #[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 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 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 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 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 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 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), };
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 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 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 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 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 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 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 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)); Ok(tmp)
2087 },
2088 )
2089 .unwrap();
2090
2091 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 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 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), };
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 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 #[derive(Default)]
2244 struct RecordingRestorer {
2245 sidecars: Vec<(MemberSpec, Vec<u8>)>,
2246 begins: Vec<String>,
2247 finishes: Vec<(String, bool)>,
2248 cur: Option<Vec<u8>>,
2250 committed_chunked: Vec<(MemberSpec, Vec<u8>)>,
2252 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 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 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 assert_eq!(read_manifest_version(&bundle), BUNDLE_FORMAT_VERSION_V3);
2341 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 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 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 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 }
2387
2388 #[test]
2389 fn v3_chunked_trailer_mismatch_aborts_and_never_commits() {
2390 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), };
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 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(); }
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 let td = tempfile::tempdir().unwrap();
2514 let stage = td.path().join("stage");
2515 std::fs::create_dir_all(&stage).unwrap();
2516
2517 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 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 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 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}