use std::collections::BTreeMap;
use std::fmt;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
mod assemble;
pub use assemble::{
assemble_bundle, assemble_self_bundle, assemble_self_bundle_with, assemble_vanilla_bundle,
collect_dir, BundleFile,
};
#[cfg(feature = "store")]
mod store;
#[cfg(feature = "store")]
pub use store::{materialize_bundle, publish_bundle, BundleCache, PublishReport};
pub const SCHEMA_VERSION: u32 = 1;
#[derive(Debug, thiserror::Error)]
pub enum BundleError {
#[error("malformed bundle manifest: {0}")]
Manifest(String),
#[error("unsupported bundle schema_version {found} (this build speaks {SCHEMA_VERSION})")]
SchemaVersion { found: u32 },
#[error("unrecognized bundle runtime {0:?} (expected \"self\" or \"mesofact/<version>\")")]
Runtime(String),
#[error("blake3 mismatch for {what}: manifest says {expected}, bytes hash to {actual}")]
HashMismatch {
what: String,
expected: String,
actual: String,
},
#[error("bundle io: {0}")]
Io(String),
#[error("missing blob {key} for path {path:?} while materializing bundle")]
MissingBlob { key: String, path: String },
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct BundleHash(String);
impl BundleHash {
pub fn parse(s: impl Into<String>) -> Result<Self, BundleError> {
let s = s.into();
if s.len() != 64 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(BundleError::Manifest(format!(
"blake3 hash must be exactly 64 hex digits, got {s:?}"
)));
}
Ok(BundleHash(s.to_ascii_lowercase()))
}
pub fn of(bytes: &[u8]) -> Self {
BundleHash(blake3::hash(bytes).to_hex().to_string())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for BundleHash {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl Serialize for BundleHash {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.0)
}
}
impl<'de> Deserialize<'de> for BundleHash {
fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
let s = String::deserialize(de)?;
BundleHash::parse(s).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BundleRuntime {
Mesofact { version: String },
SelfContained,
}
impl BundleRuntime {
pub fn as_wire(&self) -> String {
match self {
BundleRuntime::SelfContained => "self".to_string(),
BundleRuntime::Mesofact { version } => format!("mesofact/{version}"),
}
}
pub fn parse(s: &str) -> Result<Self, BundleError> {
if s == "self" {
return Ok(BundleRuntime::SelfContained);
}
if let Some(version) = s.strip_prefix("mesofact/") {
if !version.is_empty() {
return Ok(BundleRuntime::Mesofact {
version: version.to_string(),
});
}
}
Err(BundleError::Runtime(s.to_string()))
}
pub fn is_self_contained(&self) -> bool {
matches!(self, BundleRuntime::SelfContained)
}
}
impl Serialize for BundleRuntime {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.as_wire())
}
}
impl<'de> Deserialize<'de> for BundleRuntime {
fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
let s = String::deserialize(de)?;
BundleRuntime::parse(&s).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BundleManifest {
pub schema_version: u32,
pub name: String,
pub runtime: BundleRuntime,
#[serde(default)]
pub content: BTreeMap<String, BundleHash>,
}
impl BundleManifest {
pub fn from_toml_str(s: &str) -> Result<Self, BundleError> {
let manifest: BundleManifest =
toml::from_str(s).map_err(|e| BundleError::Manifest(e.to_string()))?;
if manifest.schema_version != SCHEMA_VERSION {
return Err(BundleError::SchemaVersion {
found: manifest.schema_version,
});
}
Ok(manifest)
}
pub fn to_toml_string(&self) -> Result<String, BundleError> {
toml::to_string_pretty(self).map_err(|e| BundleError::Manifest(e.to_string()))
}
pub fn digest(&self) -> BundleHash {
let mut hasher = blake3::Hasher::new();
let mut feed = |bytes: &[u8]| {
hasher.update(&(bytes.len() as u64).to_le_bytes());
hasher.update(bytes);
};
feed(b"yah-mesofact-bundle/v1");
feed(&self.schema_version.to_le_bytes());
feed(self.name.as_bytes());
feed(self.runtime.as_wire().as_bytes());
feed(&(self.content.len() as u64).to_le_bytes());
for (path, hash) in &self.content {
feed(path.as_bytes());
feed(hash.as_str().as_bytes());
}
BundleHash(hasher.finalize().to_hex().to_string())
}
}
pub fn blob_key(hash: &BundleHash) -> String {
format!("blobs/{}", hash.as_str())
}
pub fn manifest_key(digest: &BundleHash) -> String {
format!("manifests/{}", digest.as_str())
}
#[cfg(test)]
mod tests {
use super::*;
fn hash_of(s: &str) -> BundleHash {
BundleHash::of(s.as_bytes())
}
fn sample() -> BundleManifest {
let mut content = BTreeMap::new();
content.insert("app/index.html".to_string(), hash_of("<html>"));
content.insert("app/mesofact.routes.ts".to_string(), hash_of("routes"));
BundleManifest {
schema_version: SCHEMA_VERSION,
name: "yah-marketing".to_string(),
runtime: BundleRuntime::Mesofact {
version: "0.8.18".to_string(),
},
content,
}
}
#[test]
fn hash_rejects_non_hex_and_wrong_length() {
assert!(BundleHash::parse("zz").is_err());
assert!(BundleHash::parse("g".repeat(64)).is_err());
assert!(BundleHash::parse("a".repeat(63)).is_err());
assert!(BundleHash::parse("A".repeat(64)).is_ok()); assert_eq!(BundleHash::parse("A".repeat(64)).unwrap().as_str(), "a".repeat(64));
}
#[test]
fn runtime_round_trips_both_forms() {
assert_eq!(BundleRuntime::parse("self").unwrap(), BundleRuntime::SelfContained);
assert_eq!(
BundleRuntime::parse("mesofact/0.8.18").unwrap(),
BundleRuntime::Mesofact { version: "0.8.18".into() }
);
assert_eq!(BundleRuntime::SelfContained.as_wire(), "self");
assert_eq!(
BundleRuntime::Mesofact { version: "1.2.3".into() }.as_wire(),
"mesofact/1.2.3"
);
assert!(BundleRuntime::parse("mesofact/").is_err());
assert!(BundleRuntime::parse("caddy").is_err());
}
#[test]
fn manifest_round_trips_through_toml() {
let m = sample();
let text = m.to_toml_string().unwrap();
let back = BundleManifest::from_toml_str(&text).unwrap();
assert_eq!(m, back);
}
#[test]
fn manifest_parses_the_doc_shape() {
let h = "a".repeat(64);
let text = format!(
r#"
schema_version = 1
name = "yah-marketing"
runtime = "mesofact/0.8.18"
[content]
"app/mesofact.routes.ts" = "{h}"
"#
);
let m = BundleManifest::from_toml_str(&text).unwrap();
assert_eq!(m.name, "yah-marketing");
assert!(matches!(m.runtime, BundleRuntime::Mesofact { .. }));
assert_eq!(m.content.len(), 1);
}
#[test]
fn unknown_schema_version_is_rejected() {
let text = r#"schema_version = 999
name = "x"
runtime = "self"
"#;
assert!(matches!(
BundleManifest::from_toml_str(text),
Err(BundleError::SchemaVersion { found: 999 })
));
}
#[test]
fn digest_is_deterministic_and_order_independent() {
let mut a = BTreeMap::new();
a.insert("z".to_string(), hash_of("1"));
a.insert("a".to_string(), hash_of("2"));
let mut b = BTreeMap::new();
b.insert("a".to_string(), hash_of("2"));
b.insert("z".to_string(), hash_of("1"));
let ma = BundleManifest { content: a, ..sample() };
let mb = BundleManifest { content: b, ..sample() };
assert_eq!(ma.digest(), mb.digest());
}
#[test]
fn digest_changes_when_any_field_changes() {
let base = sample();
let d0 = base.digest();
let mut c = base.content.clone();
c.insert("app/index.html".to_string(), hash_of("<html>changed"));
assert_ne!(BundleManifest { content: c, ..sample() }.digest(), d0);
assert_ne!(BundleManifest { name: "other".into(), ..sample() }.digest(), d0);
assert_ne!(
BundleManifest { runtime: BundleRuntime::SelfContained, ..sample() }.digest(),
d0
);
let mut c2 = base.content.clone();
c2.insert("app/extra.js".to_string(), hash_of("x"));
assert_ne!(BundleManifest { content: c2, ..sample() }.digest(), d0);
}
#[test]
fn key_layout_is_stable() {
let h = hash_of("blob");
assert_eq!(blob_key(&h), format!("blobs/{h}"));
assert_eq!(manifest_key(&h), format!("manifests/{h}"));
}
}