1use std::collections::BTreeMap;
41use std::fmt;
42
43use serde::{Deserialize, Deserializer, Serialize, Serializer};
44
45mod assemble;
50pub use assemble::{
51 assemble_bundle, assemble_self_bundle, assemble_vanilla_bundle, collect_dir, BundleFile,
52};
53
54#[cfg(feature = "store")]
55mod store;
56#[cfg(feature = "store")]
57pub use store::{materialize_bundle, publish_bundle, BundleCache, PublishReport};
58
59pub const SCHEMA_VERSION: u32 = 1;
62
63#[derive(Debug, thiserror::Error)]
66pub enum BundleError {
67 #[error("malformed bundle manifest: {0}")]
69 Manifest(String),
70
71 #[error("unsupported bundle schema_version {found} (this build speaks {SCHEMA_VERSION})")]
73 SchemaVersion { found: u32 },
74
75 #[error("unrecognized bundle runtime {0:?} (expected \"self\" or \"mesofact/<version>\")")]
77 Runtime(String),
78
79 #[error("blake3 mismatch for {what}: manifest says {expected}, bytes hash to {actual}")]
82 HashMismatch {
83 what: String,
84 expected: String,
85 actual: String,
86 },
87
88 #[error("bundle io: {0}")]
91 Io(String),
92
93 #[error("missing blob {key} for path {path:?} while materializing bundle")]
96 MissingBlob { key: String, path: String },
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
106pub struct BundleHash(String);
107
108impl BundleHash {
109 pub fn parse(s: impl Into<String>) -> Result<Self, BundleError> {
111 let s = s.into();
112 if s.len() != 64 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
113 return Err(BundleError::Manifest(format!(
114 "blake3 hash must be exactly 64 hex digits, got {s:?}"
115 )));
116 }
117 Ok(BundleHash(s.to_ascii_lowercase()))
118 }
119
120 pub fn of(bytes: &[u8]) -> Self {
122 BundleHash(blake3::hash(bytes).to_hex().to_string())
123 }
124
125 pub fn as_str(&self) -> &str {
127 &self.0
128 }
129}
130
131impl fmt::Display for BundleHash {
132 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133 f.write_str(&self.0)
134 }
135}
136
137impl Serialize for BundleHash {
138 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
139 s.serialize_str(&self.0)
140 }
141}
142
143impl<'de> Deserialize<'de> for BundleHash {
144 fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
145 let s = String::deserialize(de)?;
146 BundleHash::parse(s).map_err(serde::de::Error::custom)
147 }
148}
149
150#[derive(Debug, Clone, PartialEq, Eq)]
156pub enum BundleRuntime {
157 Mesofact { version: String },
160
161 SelfContained,
164}
165
166impl BundleRuntime {
167 pub fn as_wire(&self) -> String {
169 match self {
170 BundleRuntime::SelfContained => "self".to_string(),
171 BundleRuntime::Mesofact { version } => format!("mesofact/{version}"),
172 }
173 }
174
175 pub fn parse(s: &str) -> Result<Self, BundleError> {
177 if s == "self" {
178 return Ok(BundleRuntime::SelfContained);
179 }
180 if let Some(version) = s.strip_prefix("mesofact/") {
181 if !version.is_empty() {
182 return Ok(BundleRuntime::Mesofact {
183 version: version.to_string(),
184 });
185 }
186 }
187 Err(BundleError::Runtime(s.to_string()))
188 }
189
190 pub fn is_self_contained(&self) -> bool {
192 matches!(self, BundleRuntime::SelfContained)
193 }
194}
195
196impl Serialize for BundleRuntime {
197 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
198 s.serialize_str(&self.as_wire())
199 }
200}
201
202impl<'de> Deserialize<'de> for BundleRuntime {
203 fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
204 let s = String::deserialize(de)?;
205 BundleRuntime::parse(&s).map_err(serde::de::Error::custom)
206 }
207}
208
209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212pub struct BundleManifest {
213 pub schema_version: u32,
215
216 pub name: String,
220
221 pub runtime: BundleRuntime,
223
224 #[serde(default)]
229 pub content: BTreeMap<String, BundleHash>,
230}
231
232impl BundleManifest {
233 pub fn from_toml_str(s: &str) -> Result<Self, BundleError> {
235 let manifest: BundleManifest =
236 toml::from_str(s).map_err(|e| BundleError::Manifest(e.to_string()))?;
237 if manifest.schema_version != SCHEMA_VERSION {
238 return Err(BundleError::SchemaVersion {
239 found: manifest.schema_version,
240 });
241 }
242 Ok(manifest)
243 }
244
245 pub fn to_toml_string(&self) -> Result<String, BundleError> {
247 toml::to_string_pretty(self).map_err(|e| BundleError::Manifest(e.to_string()))
248 }
249
250 pub fn digest(&self) -> BundleHash {
260 let mut hasher = blake3::Hasher::new();
261 let mut feed = |bytes: &[u8]| {
264 hasher.update(&(bytes.len() as u64).to_le_bytes());
265 hasher.update(bytes);
266 };
267 feed(b"yah-mesofact-bundle/v1");
268 feed(&self.schema_version.to_le_bytes());
269 feed(self.name.as_bytes());
270 feed(self.runtime.as_wire().as_bytes());
271 feed(&(self.content.len() as u64).to_le_bytes());
272 for (path, hash) in &self.content {
274 feed(path.as_bytes());
275 feed(hash.as_str().as_bytes());
276 }
277 BundleHash(hasher.finalize().to_hex().to_string())
278 }
279}
280
281pub fn blob_key(hash: &BundleHash) -> String {
285 format!("blobs/{}", hash.as_str())
286}
287
288pub fn manifest_key(digest: &BundleHash) -> String {
292 format!("manifests/{}", digest.as_str())
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 fn hash_of(s: &str) -> BundleHash {
300 BundleHash::of(s.as_bytes())
301 }
302
303 fn sample() -> BundleManifest {
304 let mut content = BTreeMap::new();
305 content.insert("app/index.html".to_string(), hash_of("<html>"));
306 content.insert("app/mesofact.routes.ts".to_string(), hash_of("routes"));
307 BundleManifest {
308 schema_version: SCHEMA_VERSION,
309 name: "yah-marketing".to_string(),
310 runtime: BundleRuntime::Mesofact {
311 version: "0.8.18".to_string(),
312 },
313 content,
314 }
315 }
316
317 #[test]
318 fn hash_rejects_non_hex_and_wrong_length() {
319 assert!(BundleHash::parse("zz").is_err());
320 assert!(BundleHash::parse("g".repeat(64)).is_err());
321 assert!(BundleHash::parse("a".repeat(63)).is_err());
322 assert!(BundleHash::parse("A".repeat(64)).is_ok()); assert_eq!(BundleHash::parse("A".repeat(64)).unwrap().as_str(), "a".repeat(64));
324 }
325
326 #[test]
327 fn runtime_round_trips_both_forms() {
328 assert_eq!(BundleRuntime::parse("self").unwrap(), BundleRuntime::SelfContained);
329 assert_eq!(
330 BundleRuntime::parse("mesofact/0.8.18").unwrap(),
331 BundleRuntime::Mesofact { version: "0.8.18".into() }
332 );
333 assert_eq!(BundleRuntime::SelfContained.as_wire(), "self");
334 assert_eq!(
335 BundleRuntime::Mesofact { version: "1.2.3".into() }.as_wire(),
336 "mesofact/1.2.3"
337 );
338 assert!(BundleRuntime::parse("mesofact/").is_err());
340 assert!(BundleRuntime::parse("caddy").is_err());
341 }
342
343 #[test]
344 fn manifest_round_trips_through_toml() {
345 let m = sample();
346 let text = m.to_toml_string().unwrap();
347 let back = BundleManifest::from_toml_str(&text).unwrap();
348 assert_eq!(m, back);
349 }
350
351 #[test]
352 fn manifest_parses_the_doc_shape() {
353 let h = "a".repeat(64);
354 let text = format!(
355 r#"
356schema_version = 1
357name = "yah-marketing"
358runtime = "mesofact/0.8.18"
359
360[content]
361"app/mesofact.routes.ts" = "{h}"
362"#
363 );
364 let m = BundleManifest::from_toml_str(&text).unwrap();
365 assert_eq!(m.name, "yah-marketing");
366 assert!(matches!(m.runtime, BundleRuntime::Mesofact { .. }));
367 assert_eq!(m.content.len(), 1);
368 }
369
370 #[test]
371 fn unknown_schema_version_is_rejected() {
372 let text = r#"schema_version = 999
373name = "x"
374runtime = "self"
375"#;
376 assert!(matches!(
377 BundleManifest::from_toml_str(text),
378 Err(BundleError::SchemaVersion { found: 999 })
379 ));
380 }
381
382 #[test]
383 fn digest_is_deterministic_and_order_independent() {
384 let mut a = BTreeMap::new();
386 a.insert("z".to_string(), hash_of("1"));
387 a.insert("a".to_string(), hash_of("2"));
388 let mut b = BTreeMap::new();
389 b.insert("a".to_string(), hash_of("2"));
390 b.insert("z".to_string(), hash_of("1"));
391 let ma = BundleManifest { content: a, ..sample() };
392 let mb = BundleManifest { content: b, ..sample() };
393 assert_eq!(ma.digest(), mb.digest());
394 }
395
396 #[test]
397 fn digest_changes_when_any_field_changes() {
398 let base = sample();
399 let d0 = base.digest();
400
401 let mut c = base.content.clone();
403 c.insert("app/index.html".to_string(), hash_of("<html>changed"));
404 assert_ne!(BundleManifest { content: c, ..sample() }.digest(), d0);
405
406 assert_ne!(BundleManifest { name: "other".into(), ..sample() }.digest(), d0);
408
409 assert_ne!(
411 BundleManifest { runtime: BundleRuntime::SelfContained, ..sample() }.digest(),
412 d0
413 );
414
415 let mut c2 = base.content.clone();
417 c2.insert("app/extra.js".to_string(), hash_of("x"));
418 assert_ne!(BundleManifest { content: c2, ..sample() }.digest(), d0);
419 }
420
421 #[test]
422 fn key_layout_is_stable() {
423 let h = hash_of("blob");
424 assert_eq!(blob_key(&h), format!("blobs/{h}"));
425 assert_eq!(manifest_key(&h), format!("manifests/{h}"));
426 }
427}