Skip to main content

yah_mesofact_bundle/
lib.rs

1//! The W272 **mesofact bundle** format — the content-addressed unit a mesofact
2//! app distributes as, plus (behind the `store` feature) the R2 blob store that
3//! publishes and materializes it.
4//!
5//! A bundle is one immutable, content-addressed directory shape:
6//!
7//! ```text
8//! <bundle>/
9//!   manifest.toml                     # identity + runtime selection
10//!   app/                              # routes/config + built TS/assets
11//!   bins/<triple>/serve               # ONLY when runtime = "self"
12//! ```
13//!
14//! ```toml
15//! # manifest.toml
16//! schema_version = 1
17//! name    = "yah-marketing"
18//! runtime = "mesofact/0.8.18"   # vanilla: resolve stock runtime from node cache
19//! # runtime = "self"            # custom: bins/<triple>/serve ships in the bundle
20//!
21//! [content]
22//! "app/mesofact.routes.ts" = "b3f1…"   # path → blake3, one row per file
23//! ```
24//!
25//! There is exactly one format; "vanilla" is the degenerate case where
26//! `runtime = "mesofact/<ver>"` and no `bins/` are carried. Immutability is the
27//! whole coherence story: any change produces a new [`BundleManifest::digest`],
28//! "update" is publish-new-digest + drain-old, and rollback is a pointer flip.
29//!
30//! This crate is deliberately light: the default feature set is serde + blake3
31//! + toml only, so the `mesofact-build` subcamp (R599-F2, a separate Cargo
32//! workspace) can link it to *emit* manifests without dragging the object-store
33//! / reqwest stack across the boundary. The `store` feature adds that surface
34//! for the cloud publish path (R599) and kamaji node materialize (R599-F4).
35//!
36//! Home of R599-F1 (the W272 bundle format + store) — the canonical `@yah:`
37//! ticket annotation lives in `oss/yubaba/crates/cloud/src/release_manifest.rs`
38//! to keep one block per ID; see W272 for the design.
39
40use std::collections::BTreeMap;
41use std::fmt;
42
43use serde::{Deserialize, Deserializer, Serialize, Serializer};
44
45/// Emitting side: walk a built app into a bundle tree + manifest. Pure
46/// `std::fs` + blake3 + toml, so it stays in the default (types-only) feature
47/// set — `mesofact-build` re-exports it, and the V8-less yah CLI can assemble a
48/// bundle without linking the build toolchain (R599-T5).
49mod 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
60/// Bundle-manifest schema version. Bumping this is a wire-format change: the
61/// materialize side rejects a manifest whose `schema_version` it does not know.
62pub const SCHEMA_VERSION: u32 = 1;
63
64/// Error surface for parsing / hashing a bundle manifest. The `store` feature
65/// adds I/O + object-store variants (see `store.rs`).
66#[derive(Debug, thiserror::Error)]
67pub enum BundleError {
68    /// `manifest.toml` failed to parse.
69    #[error("malformed bundle manifest: {0}")]
70    Manifest(String),
71
72    /// A manifest carried a `schema_version` this build doesn't understand.
73    #[error("unsupported bundle schema_version {found} (this build speaks {SCHEMA_VERSION})")]
74    SchemaVersion { found: u32 },
75
76    /// A `runtime = "..."` string was neither `self` nor `mesofact/<version>`.
77    #[error("unrecognized bundle runtime {0:?} (expected \"self\" or \"mesofact/<version>\")")]
78    Runtime(String),
79
80    /// Bytes hashed to a different digest than the manifest recorded. Carries
81    /// the offending path (or blob key) plus both hashes.
82    #[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    /// Filesystem or object-store failure. Constructed by the assemble path
90    /// (always available) and by the `store` feature's publish/materialize.
91    #[error("bundle io: {0}")]
92    Io(String),
93
94    /// A blob referenced by the manifest was absent from the store during
95    /// materialize (only constructed under `store`).
96    #[error("missing blob {key} for path {path:?} while materializing bundle")]
97    MissingBlob { key: String, path: String },
98}
99
100/// A BLAKE3 content hash: exactly 64 lowercase ASCII hex digits.
101///
102/// The content-address key for every file in a bundle *and* for the bundle's
103/// own manifest digest. Deserialization rejects anything that isn't 64 hex
104/// chars, and normalizes to lowercase so `blob_key` is stable regardless of how
105/// a hand-authored manifest cased its hashes.
106#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
107pub struct BundleHash(String);
108
109impl BundleHash {
110    /// Wrap a hex string, validating shape (64 hex chars) and lowercasing.
111    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    /// The BLAKE3 hash of `bytes`, as a [`BundleHash`].
122    pub fn of(bytes: &[u8]) -> Self {
123        BundleHash(blake3::hash(bytes).to_hex().to_string())
124    }
125
126    /// Borrow the hex string.
127    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/// Which runtime serves a bundle — the `runtime = "..."` manifest field.
152///
153/// Serialized as a single string: `"self"` for a bundle that ships its own
154/// `bins/<triple>/serve`, or `"mesofact/<version>"` for a vanilla bundle that
155/// resolves the stock `mesofact serve` runtime from the node's runtime cache.
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub enum BundleRuntime {
158    /// Vanilla: resolve the stock `mesofact serve` runtime asset of this
159    /// version from the node cache. No binaries in the bundle.
160    Mesofact { version: String },
161
162    /// Custom: the bundle carries `bins/<triple>/serve` and is served by its
163    /// own binary. No node runtime asset is resolved.
164    SelfContained,
165}
166
167impl BundleRuntime {
168    /// The wire string form (`"self"` or `"mesofact/<version>"`).
169    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    /// Parse the wire string form.
177    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    /// Whether the bundle ships its own serve binaries (`runtime = "self"`).
192    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/// The parsed `manifest.toml` at a bundle's root — the bundle's identity plus
211/// the per-file content map that makes it content-addressed.
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
213pub struct BundleManifest {
214    /// Wire-format version. Always [`SCHEMA_VERSION`] today.
215    pub schema_version: u32,
216
217    /// Human-readable bundle name, e.g. `"yah-marketing"`. Not part of the
218    /// content-address surface beyond the digest — two bundles with the same
219    /// bytes but different names have different digests.
220    pub name: String,
221
222    /// Runtime selection (`"self"` / `"mesofact/<ver>"`).
223    pub runtime: BundleRuntime,
224
225    /// Every file the bundle carries, keyed by its path *within the bundle*
226    /// (e.g. `"app/dist/index.html"`, `"bins/x86_64-unknown-linux-musl/serve"`)
227    /// mapped to its BLAKE3 content hash. Sorted (BTreeMap) so the manifest
228    /// serialization — and therefore the digest — is deterministic.
229    #[serde(default)]
230    pub content: BTreeMap<String, BundleHash>,
231}
232
233impl BundleManifest {
234    /// Parse a `manifest.toml` string, rejecting an unknown schema version.
235    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    /// Serialize back to `manifest.toml` form.
247    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    /// The bundle's content-address: a BLAKE3 digest over the manifest's
252    /// identity + content map.
253    ///
254    /// Computed over a canonical, order-stable byte encoding (not the TOML text,
255    /// which whitespace/comments could perturb): `schema_version`, `name`, the
256    /// runtime wire string, then every `(path, hash)` pair in sorted path order.
257    /// Any change to any file's bytes (its hash), the name, the runtime, or the
258    /// file set flips the digest — that immutability is what makes rollback a
259    /// pointer flip and eviction a no-invalidation affair (W272 §1).
260    pub fn digest(&self) -> BundleHash {
261        let mut hasher = blake3::Hasher::new();
262        // Domain-separate + length-prefix every field so no two distinct
263        // manifests can collide by field-boundary ambiguity.
264        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        // BTreeMap iterates in sorted key order — deterministic.
274        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
282/// Object-store key for a content blob: `blobs/<blake3>`. Every file across
283/// every bundle dedupes on this key — two bundles sharing a byte-identical file
284/// share the one blob.
285pub fn blob_key(hash: &BundleHash) -> String {
286    format!("blobs/{}", hash.as_str())
287}
288
289/// Object-store key for a bundle manifest: `manifests/<digest>`. Immutable —
290/// the digest is derived from the manifest, so re-publishing an identical
291/// bundle writes the same key with the same bytes.
292pub 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()); // hex, gets lowercased
324        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        // Malformed runtimes reject.
340        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        // Insertion order differs but BTreeMap normalizes — same digest.
386        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        // Different file bytes (hash) → different digest.
403        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        // Different name → different digest.
408        assert_ne!(BundleManifest { name: "other".into(), ..sample() }.digest(), d0);
409
410        // Different runtime → different digest.
411        assert_ne!(
412            BundleManifest { runtime: BundleRuntime::SelfContained, ..sample() }.digest(),
413            d0
414        );
415
416        // Extra file → different digest.
417        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}