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}
183
184impl Manifest {
185 pub fn validate(&self) -> Result<(), BundleError> {
188 if self.magic != BUNDLE_MAGIC {
189 return Err(BundleError::BadMagic {
190 expected: BUNDLE_MAGIC.to_string(),
191 found: self.magic.clone(),
192 });
193 }
194 if self.format_version != BUNDLE_FORMAT_VERSION {
195 return Err(BundleError::BadVersion {
196 found: self.format_version,
197 supported: BUNDLE_FORMAT_VERSION,
198 });
199 }
200 Ok(())
201 }
202
203 pub fn pg_members(&self) -> impl Iterator<Item = &ManifestMember> {
205 self.members.iter().filter(|m| m.kind == MemberKind::PgDump)
206 }
207}
208
209#[derive(Debug, Clone)]
211pub struct StagedMember {
212 pub path: String,
214 pub kind: MemberKind,
216 pub source: PathBuf,
218 pub pg: Option<PgMemberInfo>,
220}
221
222fn validate_member_path(path: &str) -> Result<(), BundleError> {
225 use std::path::Component;
226 if path.is_empty() {
227 return Err(BundleError::UnsafePath(path.to_string()));
228 }
229 let p = Path::new(path);
230 if p.is_absolute() {
231 return Err(BundleError::UnsafePath(path.to_string()));
232 }
233 for c in p.components() {
234 if !matches!(c, Component::Normal(_)) {
235 return Err(BundleError::UnsafePath(path.to_string()));
236 }
237 }
238 Ok(())
239}
240
241fn size_and_sha256<R: Read>(mut r: R) -> io::Result<(u64, String)> {
243 let mut hasher = Sha256::new();
244 let mut buf = [0u8; 64 * 1024];
245 let mut total = 0u64;
246 loop {
247 let n = r.read(&mut buf)?;
248 if n == 0 {
249 break;
250 }
251 hasher.update(&buf[..n]);
252 total += n as u64;
253 }
254 Ok((total, hex::encode(hasher.finalize())))
255}
256
257fn append_bytes<W: Write>(
259 builder: &mut tar::Builder<W>,
260 path: &str,
261 mtime: u64,
262 data: &[u8],
263) -> Result<(), BundleError> {
264 let mut header = tar::Header::new_gnu();
265 header.set_size(data.len() as u64);
266 header.set_mode(0o600);
267 header.set_mtime(mtime);
268 builder.append_data(&mut header, path, data)?;
270 Ok(())
271}
272
273pub fn write_bundle(
278 out: &Path,
279 created_at: u64,
280 data_only: bool,
281 source: SourceInfo,
282 staged: &[StagedMember],
283) -> Result<Manifest, BundleError> {
284 let mut members = Vec::with_capacity(staged.len());
286 for m in staged {
287 validate_member_path(&m.path)?;
288 let f = std::fs::File::open(&m.source)?;
289 let (size, sha256) = size_and_sha256(io::BufReader::new(f))?;
290 members.push(ManifestMember {
291 path: m.path.clone(),
292 kind: m.kind,
293 size,
294 sha256,
295 pg: m.pg.clone(),
296 });
297 }
298 let manifest = Manifest {
299 magic: BUNDLE_MAGIC.to_string(),
300 format_version: BUNDLE_FORMAT_VERSION,
301 created_at,
302 data_only,
303 source,
304 members,
305 };
306 manifest.validate()?; let file = std::fs::File::create(out)?;
310 let mut builder = tar::Builder::new(io::BufWriter::new(file));
311 let manifest_json = serde_json::to_vec_pretty(&manifest)?;
312 append_bytes(&mut builder, MANIFEST_PATH, created_at, &manifest_json)?;
313 for (sm, mm) in staged.iter().zip(&manifest.members) {
314 let mut f = std::fs::File::open(&sm.source)?;
315 let mut header = tar::Header::new_gnu();
316 header.set_size(mm.size);
317 header.set_mode(0o600);
318 header.set_mtime(created_at);
319 builder.append_data(&mut header, &sm.path, &mut f)?;
320 }
321 let mut inner = builder.into_inner()?;
322 inner.flush()?;
323 Ok(manifest)
324}
325
326pub fn read_manifest(bundle: &Path) -> Result<Manifest, BundleError> {
329 let file = std::fs::File::open(bundle)?;
330 let mut ar = tar::Archive::new(io::BufReader::new(file));
331 let mut entries = ar.entries()?;
332 let first = entries
333 .next()
334 .ok_or_else(|| BundleError::MissingManifest(MANIFEST_PATH.to_string()))??;
335 let path = first.path()?.to_string_lossy().into_owned();
336 if path != MANIFEST_PATH {
337 return Err(BundleError::MissingManifest(MANIFEST_PATH.to_string()));
338 }
339 let mut buf = String::new();
340 let mut first = first;
341 first.read_to_string(&mut buf)?;
342 let manifest: Manifest = serde_json::from_str(&buf)?;
343 manifest.validate()?;
344 Ok(manifest)
345}
346
347pub fn extract_and_verify(bundle: &Path, out_dir: &Path) -> Result<Manifest, BundleError> {
352 let manifest = read_manifest(bundle)?;
353 let file = std::fs::File::open(bundle)?;
354 let mut ar = tar::Archive::new(io::BufReader::new(file));
355 let mut seen: HashSet<String> = HashSet::new();
356 for entry in ar.entries()? {
357 let mut entry = entry?;
358 let path = entry.path()?.to_string_lossy().into_owned();
359 if path == MANIFEST_PATH {
360 continue;
361 }
362 validate_member_path(&path)?;
363 let mm = manifest
364 .members
365 .iter()
366 .find(|m| m.path == path)
367 .ok_or_else(|| BundleError::UnexpectedMember(path.clone()))?;
368 let dst = out_dir.join(&path);
369 if let Some(parent) = dst.parent() {
370 std::fs::create_dir_all(parent)?;
371 }
372 let mut hasher = Sha256::new();
374 let mut f = io::BufWriter::new(std::fs::File::create(&dst)?);
375 let mut buf = [0u8; 64 * 1024];
376 loop {
377 let n = entry.read(&mut buf)?;
378 if n == 0 {
379 break;
380 }
381 hasher.update(&buf[..n]);
382 f.write_all(&buf[..n])?;
383 }
384 f.flush()?;
385 let actual = hex::encode(hasher.finalize());
386 if actual != mm.sha256 {
387 return Err(BundleError::ChecksumMismatch {
388 path,
389 expected: mm.sha256.clone(),
390 actual,
391 });
392 }
393 seen.insert(path);
394 }
395 for m in &manifest.members {
396 if !seen.contains(&m.path) {
397 return Err(BundleError::MissingMember(m.path.clone()));
398 }
399 }
400 Ok(manifest)
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406
407 fn write_staging(dir: &Path, name: &str, bytes: &[u8]) -> PathBuf {
408 let p = dir.join(name);
409 std::fs::write(&p, bytes).unwrap();
410 p
411 }
412
413 #[test]
414 fn round_trips_a_bundle_and_verifies_members() {
415 let td = tempfile::tempdir().unwrap();
416 let stage = td.path().join("stage");
417 std::fs::create_dir_all(&stage).unwrap();
418 let kv = write_staging(&stage, "kv.bin", b"BRKVDUMP\x01\x02\x03");
419 let pg = write_staging(&stage, "app.pgc", &vec![7u8; 5000]);
420
421 let bundle = td.path().join("out.brproj");
422 let staged = vec![
423 StagedMember {
424 path: KV_MEMBER_PATH.to_string(),
425 kind: MemberKind::Kv,
426 source: kv,
427 pg: None,
428 },
429 StagedMember {
430 path: "sql/app.pgc".to_string(),
431 kind: MemberKind::PgDump,
432 source: pg,
433 pg: Some(PgMemberInfo {
434 server_version_num: "160004".to_string(),
435 client_image: "pgvector/pgvector:pg16".to_string(),
436 schema_fingerprint: Some("deadbeef".to_string()),
437 }),
438 },
439 ];
440 let written = write_bundle(
441 &bundle,
442 1_700_000_000,
443 true,
444 SourceInfo {
445 project: "acme".to_string(),
446 site: None,
447 },
448 &staged,
449 )
450 .unwrap();
451 assert_eq!(written.magic, BUNDLE_MAGIC);
452 assert!(written.data_only);
453 assert_eq!(written.pg_members().count(), 1);
454
455 let m = read_manifest(&bundle).unwrap();
457 assert_eq!(m, written);
458 assert_eq!(
459 m.pg_members()
460 .next()
461 .unwrap()
462 .pg
463 .as_ref()
464 .unwrap()
465 .client_image,
466 "pgvector/pgvector:pg16"
467 );
468
469 let out = td.path().join("extract");
471 let m2 = extract_and_verify(&bundle, &out).unwrap();
472 assert_eq!(m2, written);
473 assert_eq!(
474 std::fs::read(out.join("sql/app.pgc")).unwrap(),
475 vec![7u8; 5000]
476 );
477 assert_eq!(
478 std::fs::read(out.join(KV_MEMBER_PATH)).unwrap(),
479 b"BRKVDUMP\x01\x02\x03"
480 );
481 }
482
483 #[test]
484 fn rejects_a_foreign_tar_and_a_future_version() {
485 let td = tempfile::tempdir().unwrap();
487 let bad = td.path().join("bad.tar");
488 {
489 let mut b = tar::Builder::new(std::fs::File::create(&bad).unwrap());
490 let m = Manifest {
491 magic: "NOTBRPROJ".to_string(),
492 format_version: BUNDLE_FORMAT_VERSION,
493 created_at: 0,
494 data_only: true,
495 source: SourceInfo {
496 project: "x".to_string(),
497 site: None,
498 },
499 members: vec![],
500 };
501 let j = serde_json::to_vec(&m).unwrap();
502 append_bytes(&mut b, MANIFEST_PATH, 0, &j).unwrap();
503 b.finish().unwrap();
504 }
505 assert!(matches!(
506 read_manifest(&bad),
507 Err(BundleError::BadMagic { .. })
508 ));
509
510 let futv = td.path().join("futv.tar");
512 {
513 let mut b = tar::Builder::new(std::fs::File::create(&futv).unwrap());
514 let m = Manifest {
515 magic: BUNDLE_MAGIC.to_string(),
516 format_version: BUNDLE_FORMAT_VERSION + 99,
517 created_at: 0,
518 data_only: true,
519 source: SourceInfo {
520 project: "x".to_string(),
521 site: None,
522 },
523 members: vec![],
524 };
525 let j = serde_json::to_vec(&m).unwrap();
526 append_bytes(&mut b, MANIFEST_PATH, 0, &j).unwrap();
527 b.finish().unwrap();
528 }
529 assert!(matches!(
530 read_manifest(&futv),
531 Err(BundleError::BadVersion { .. })
532 ));
533 }
534
535 #[test]
536 fn extract_catches_a_checksum_mismatch() {
537 let td = tempfile::tempdir().unwrap();
540 let bad = td.path().join("tamper.tar");
541 {
542 let mut b = tar::Builder::new(std::fs::File::create(&bad).unwrap());
543 let m = Manifest {
544 magic: BUNDLE_MAGIC.to_string(),
545 format_version: BUNDLE_FORMAT_VERSION,
546 created_at: 0,
547 data_only: true,
548 source: SourceInfo {
549 project: "x".to_string(),
550 site: None,
551 },
552 members: vec![ManifestMember {
553 path: "sql/app.pgc".to_string(),
554 kind: MemberKind::PgDump,
555 size: 3,
556 sha256: "00".repeat(32), pg: None,
558 }],
559 };
560 let j = serde_json::to_vec(&m).unwrap();
561 append_bytes(&mut b, MANIFEST_PATH, 0, &j).unwrap();
562 append_bytes(&mut b, "sql/app.pgc", 0, b"abc").unwrap();
563 b.finish().unwrap();
564 }
565 let out = td.path().join("x");
566 assert!(matches!(
567 extract_and_verify(&bad, &out),
568 Err(BundleError::ChecksumMismatch { .. })
569 ));
570 }
571
572 #[test]
573 fn rejects_unsafe_member_paths() {
574 assert!(validate_member_path("sql/app.pgc").is_ok());
575 assert!(validate_member_path("libsql/acme/site/db.sqlite").is_ok());
576 assert!(validate_member_path("/etc/passwd").is_err());
577 assert!(validate_member_path("../escape").is_err());
578 assert!(validate_member_path("a/../b").is_err());
579 assert!(validate_member_path("").is_err());
580 }
581}