use std::collections::HashSet;
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
pub const BUNDLE_MAGIC: &str = "BRPROJ";
pub const BUNDLE_FORMAT_VERSION: u32 = 1;
pub const MANIFEST_PATH: &str = "MANIFEST";
pub const KV_MEMBER_PATH: &str = "kv.brkvdump";
pub const IDENTITY_MEMBER_PATH: &str = "identity/identity.brid";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MemberKind {
PgDump,
MysqlDump,
Libsql,
Kv,
Blob,
Identity,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PgMemberInfo {
pub server_version_num: String,
pub client_image: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema_fingerprint: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManifestMember {
pub path: String,
pub kind: MemberKind,
pub size: u64,
pub sha256: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pg: Option<PgMemberInfo>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceInfo {
pub project: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub site: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Manifest {
pub magic: String,
pub format_version: u32,
pub created_at: u64,
pub data_only: bool,
pub source: SourceInfo,
pub members: Vec<ManifestMember>,
}
#[derive(Debug, thiserror::Error)]
pub enum BundleError {
#[error("bundle io: {0}")]
Io(#[from] io::Error),
#[error("bundle manifest json: {0}")]
Json(#[from] serde_json::Error),
#[error("not a boatramp project bundle: expected magic {expected:?}, found {found:?}")]
BadMagic {
expected: String,
found: String,
},
#[error("unsupported bundle format version {found} (this build reads {supported})")]
BadVersion {
found: u32,
supported: u32,
},
#[error("bundle is missing its {0:?} entry as the first entry")]
MissingManifest(String),
#[error("checksum MISMATCH for member {path:?}: manifest {expected}, extracted {actual}")]
ChecksumMismatch {
path: String,
expected: String,
actual: String,
},
#[error("member {0:?} named in the manifest is absent from the bundle")]
MissingMember(String),
#[error("member {0:?} is present in the bundle but absent from the manifest")]
UnexpectedMember(String),
#[error("unsafe member path {0:?} in bundle (absolute or traversal)")]
UnsafePath(String),
}
impl Manifest {
pub fn validate(&self) -> Result<(), BundleError> {
if self.magic != BUNDLE_MAGIC {
return Err(BundleError::BadMagic {
expected: BUNDLE_MAGIC.to_string(),
found: self.magic.clone(),
});
}
if self.format_version != BUNDLE_FORMAT_VERSION {
return Err(BundleError::BadVersion {
found: self.format_version,
supported: BUNDLE_FORMAT_VERSION,
});
}
Ok(())
}
pub fn pg_members(&self) -> impl Iterator<Item = &ManifestMember> {
self.members.iter().filter(|m| m.kind == MemberKind::PgDump)
}
}
#[derive(Debug, Clone)]
pub struct StagedMember {
pub path: String,
pub kind: MemberKind,
pub source: PathBuf,
pub pg: Option<PgMemberInfo>,
}
fn validate_member_path(path: &str) -> Result<(), BundleError> {
use std::path::Component;
if path.is_empty() {
return Err(BundleError::UnsafePath(path.to_string()));
}
let p = Path::new(path);
if p.is_absolute() {
return Err(BundleError::UnsafePath(path.to_string()));
}
for c in p.components() {
if !matches!(c, Component::Normal(_)) {
return Err(BundleError::UnsafePath(path.to_string()));
}
}
Ok(())
}
fn size_and_sha256<R: Read>(mut r: R) -> io::Result<(u64, String)> {
let mut hasher = Sha256::new();
let mut buf = [0u8; 64 * 1024];
let mut total = 0u64;
loop {
let n = r.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
total += n as u64;
}
Ok((total, hex::encode(hasher.finalize())))
}
fn append_bytes<W: Write>(
builder: &mut tar::Builder<W>,
path: &str,
mtime: u64,
data: &[u8],
) -> Result<(), BundleError> {
let mut header = tar::Header::new_gnu();
header.set_size(data.len() as u64);
header.set_mode(0o600);
header.set_mtime(mtime);
builder.append_data(&mut header, path, data)?;
Ok(())
}
pub fn write_bundle(
out: &Path,
created_at: u64,
data_only: bool,
source: SourceInfo,
staged: &[StagedMember],
) -> Result<Manifest, BundleError> {
let mut members = Vec::with_capacity(staged.len());
for m in staged {
validate_member_path(&m.path)?;
let f = std::fs::File::open(&m.source)?;
let (size, sha256) = size_and_sha256(io::BufReader::new(f))?;
members.push(ManifestMember {
path: m.path.clone(),
kind: m.kind,
size,
sha256,
pg: m.pg.clone(),
});
}
let manifest = Manifest {
magic: BUNDLE_MAGIC.to_string(),
format_version: BUNDLE_FORMAT_VERSION,
created_at,
data_only,
source,
members,
};
manifest.validate()?;
let file = std::fs::File::create(out)?;
let mut builder = tar::Builder::new(io::BufWriter::new(file));
let manifest_json = serde_json::to_vec_pretty(&manifest)?;
append_bytes(&mut builder, MANIFEST_PATH, created_at, &manifest_json)?;
for (sm, mm) in staged.iter().zip(&manifest.members) {
let mut f = std::fs::File::open(&sm.source)?;
let mut header = tar::Header::new_gnu();
header.set_size(mm.size);
header.set_mode(0o600);
header.set_mtime(created_at);
builder.append_data(&mut header, &sm.path, &mut f)?;
}
let mut inner = builder.into_inner()?;
inner.flush()?;
Ok(manifest)
}
pub fn read_manifest(bundle: &Path) -> Result<Manifest, BundleError> {
let file = std::fs::File::open(bundle)?;
let mut ar = tar::Archive::new(io::BufReader::new(file));
let mut entries = ar.entries()?;
let first = entries
.next()
.ok_or_else(|| BundleError::MissingManifest(MANIFEST_PATH.to_string()))??;
let path = first.path()?.to_string_lossy().into_owned();
if path != MANIFEST_PATH {
return Err(BundleError::MissingManifest(MANIFEST_PATH.to_string()));
}
let mut buf = String::new();
let mut first = first;
first.read_to_string(&mut buf)?;
let manifest: Manifest = serde_json::from_str(&buf)?;
manifest.validate()?;
Ok(manifest)
}
pub fn extract_and_verify(bundle: &Path, out_dir: &Path) -> Result<Manifest, BundleError> {
let manifest = read_manifest(bundle)?;
let file = std::fs::File::open(bundle)?;
let mut ar = tar::Archive::new(io::BufReader::new(file));
let mut seen: HashSet<String> = HashSet::new();
for entry in ar.entries()? {
let mut entry = entry?;
let path = entry.path()?.to_string_lossy().into_owned();
if path == MANIFEST_PATH {
continue;
}
validate_member_path(&path)?;
let mm = manifest
.members
.iter()
.find(|m| m.path == path)
.ok_or_else(|| BundleError::UnexpectedMember(path.clone()))?;
let dst = out_dir.join(&path);
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent)?;
}
let mut hasher = Sha256::new();
let mut f = io::BufWriter::new(std::fs::File::create(&dst)?);
let mut buf = [0u8; 64 * 1024];
loop {
let n = entry.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
f.write_all(&buf[..n])?;
}
f.flush()?;
let actual = hex::encode(hasher.finalize());
if actual != mm.sha256 {
return Err(BundleError::ChecksumMismatch {
path,
expected: mm.sha256.clone(),
actual,
});
}
seen.insert(path);
}
for m in &manifest.members {
if !seen.contains(&m.path) {
return Err(BundleError::MissingMember(m.path.clone()));
}
}
Ok(manifest)
}
#[cfg(test)]
mod tests {
use super::*;
fn write_staging(dir: &Path, name: &str, bytes: &[u8]) -> PathBuf {
let p = dir.join(name);
std::fs::write(&p, bytes).unwrap();
p
}
#[test]
fn round_trips_a_bundle_and_verifies_members() {
let td = tempfile::tempdir().unwrap();
let stage = td.path().join("stage");
std::fs::create_dir_all(&stage).unwrap();
let kv = write_staging(&stage, "kv.bin", b"BRKVDUMP\x01\x02\x03");
let pg = write_staging(&stage, "app.pgc", &vec![7u8; 5000]);
let bundle = td.path().join("out.brproj");
let staged = vec![
StagedMember {
path: KV_MEMBER_PATH.to_string(),
kind: MemberKind::Kv,
source: kv,
pg: None,
},
StagedMember {
path: "sql/app.pgc".to_string(),
kind: MemberKind::PgDump,
source: pg,
pg: Some(PgMemberInfo {
server_version_num: "160004".to_string(),
client_image: "pgvector/pgvector:pg16".to_string(),
schema_fingerprint: Some("deadbeef".to_string()),
}),
},
];
let written = write_bundle(
&bundle,
1_700_000_000,
true,
SourceInfo {
project: "acme".to_string(),
site: None,
},
&staged,
)
.unwrap();
assert_eq!(written.magic, BUNDLE_MAGIC);
assert!(written.data_only);
assert_eq!(written.pg_members().count(), 1);
let m = read_manifest(&bundle).unwrap();
assert_eq!(m, written);
assert_eq!(
m.pg_members()
.next()
.unwrap()
.pg
.as_ref()
.unwrap()
.client_image,
"pgvector/pgvector:pg16"
);
let out = td.path().join("extract");
let m2 = extract_and_verify(&bundle, &out).unwrap();
assert_eq!(m2, written);
assert_eq!(
std::fs::read(out.join("sql/app.pgc")).unwrap(),
vec![7u8; 5000]
);
assert_eq!(
std::fs::read(out.join(KV_MEMBER_PATH)).unwrap(),
b"BRKVDUMP\x01\x02\x03"
);
}
#[test]
fn rejects_a_foreign_tar_and_a_future_version() {
let td = tempfile::tempdir().unwrap();
let bad = td.path().join("bad.tar");
{
let mut b = tar::Builder::new(std::fs::File::create(&bad).unwrap());
let m = Manifest {
magic: "NOTBRPROJ".to_string(),
format_version: BUNDLE_FORMAT_VERSION,
created_at: 0,
data_only: true,
source: SourceInfo {
project: "x".to_string(),
site: None,
},
members: vec![],
};
let j = serde_json::to_vec(&m).unwrap();
append_bytes(&mut b, MANIFEST_PATH, 0, &j).unwrap();
b.finish().unwrap();
}
assert!(matches!(
read_manifest(&bad),
Err(BundleError::BadMagic { .. })
));
let futv = td.path().join("futv.tar");
{
let mut b = tar::Builder::new(std::fs::File::create(&futv).unwrap());
let m = Manifest {
magic: BUNDLE_MAGIC.to_string(),
format_version: BUNDLE_FORMAT_VERSION + 99,
created_at: 0,
data_only: true,
source: SourceInfo {
project: "x".to_string(),
site: None,
},
members: vec![],
};
let j = serde_json::to_vec(&m).unwrap();
append_bytes(&mut b, MANIFEST_PATH, 0, &j).unwrap();
b.finish().unwrap();
}
assert!(matches!(
read_manifest(&futv),
Err(BundleError::BadVersion { .. })
));
}
#[test]
fn extract_catches_a_checksum_mismatch() {
let td = tempfile::tempdir().unwrap();
let bad = td.path().join("tamper.tar");
{
let mut b = tar::Builder::new(std::fs::File::create(&bad).unwrap());
let m = Manifest {
magic: BUNDLE_MAGIC.to_string(),
format_version: BUNDLE_FORMAT_VERSION,
created_at: 0,
data_only: true,
source: SourceInfo {
project: "x".to_string(),
site: None,
},
members: vec![ManifestMember {
path: "sql/app.pgc".to_string(),
kind: MemberKind::PgDump,
size: 3,
sha256: "00".repeat(32), pg: None,
}],
};
let j = serde_json::to_vec(&m).unwrap();
append_bytes(&mut b, MANIFEST_PATH, 0, &j).unwrap();
append_bytes(&mut b, "sql/app.pgc", 0, b"abc").unwrap();
b.finish().unwrap();
}
let out = td.path().join("x");
assert!(matches!(
extract_and_verify(&bad, &out),
Err(BundleError::ChecksumMismatch { .. })
));
}
#[test]
fn rejects_unsafe_member_paths() {
assert!(validate_member_path("sql/app.pgc").is_ok());
assert!(validate_member_path("libsql/acme/site/db.sqlite").is_ok());
assert!(validate_member_path("/etc/passwd").is_err());
assert!(validate_member_path("../escape").is_err());
assert!(validate_member_path("a/../b").is_err());
assert!(validate_member_path("").is_err());
}
}