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