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_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
59/// Bundle-manifest schema version. Bumping this is a wire-format change: the
60/// materialize side rejects a manifest whose `schema_version` it does not know.
61pub const SCHEMA_VERSION: u32 = 1;
62
63/// Error surface for parsing / hashing a bundle manifest. The `store` feature
64/// adds I/O + object-store variants (see `store.rs`).
65#[derive(Debug, thiserror::Error)]
66pub enum BundleError {
67    /// `manifest.toml` failed to parse.
68    #[error("malformed bundle manifest: {0}")]
69    Manifest(String),
70
71    /// A manifest carried a `schema_version` this build doesn't understand.
72    #[error("unsupported bundle schema_version {found} (this build speaks {SCHEMA_VERSION})")]
73    SchemaVersion { found: u32 },
74
75    /// A `runtime = "..."` string was neither `self` nor `mesofact/<version>`.
76    #[error("unrecognized bundle runtime {0:?} (expected \"self\" or \"mesofact/<version>\")")]
77    Runtime(String),
78
79    /// Bytes hashed to a different digest than the manifest recorded. Carries
80    /// the offending path (or blob key) plus both hashes.
81    #[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    /// Filesystem or object-store failure. Constructed by the assemble path
89    /// (always available) and by the `store` feature's publish/materialize.
90    #[error("bundle io: {0}")]
91    Io(String),
92
93    /// A blob referenced by the manifest was absent from the store during
94    /// materialize (only constructed under `store`).
95    #[error("missing blob {key} for path {path:?} while materializing bundle")]
96    MissingBlob { key: String, path: String },
97}
98
99/// A BLAKE3 content hash: exactly 64 lowercase ASCII hex digits.
100///
101/// The content-address key for every file in a bundle *and* for the bundle's
102/// own manifest digest. Deserialization rejects anything that isn't 64 hex
103/// chars, and normalizes to lowercase so `blob_key` is stable regardless of how
104/// a hand-authored manifest cased its hashes.
105#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
106pub struct BundleHash(String);
107
108impl BundleHash {
109    /// Wrap a hex string, validating shape (64 hex chars) and lowercasing.
110    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    /// The BLAKE3 hash of `bytes`, as a [`BundleHash`].
121    pub fn of(bytes: &[u8]) -> Self {
122        BundleHash(blake3::hash(bytes).to_hex().to_string())
123    }
124
125    /// Borrow the hex string.
126    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/// Which runtime serves a bundle — the `runtime = "..."` manifest field.
151///
152/// Serialized as a single string: `"self"` for a bundle that ships its own
153/// `bins/<triple>/serve`, or `"mesofact/<version>"` for a vanilla bundle that
154/// resolves the stock `mesofact serve` runtime from the node's runtime cache.
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub enum BundleRuntime {
157    /// Vanilla: resolve the stock `mesofact serve` runtime asset of this
158    /// version from the node cache. No binaries in the bundle.
159    Mesofact { version: String },
160
161    /// Custom: the bundle carries `bins/<triple>/serve` and is served by its
162    /// own binary. No node runtime asset is resolved.
163    SelfContained,
164}
165
166impl BundleRuntime {
167    /// The wire string form (`"self"` or `"mesofact/<version>"`).
168    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    /// Parse the wire string form.
176    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    /// Whether the bundle ships its own serve binaries (`runtime = "self"`).
191    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/// The parsed `manifest.toml` at a bundle's root — the bundle's identity plus
210/// the per-file content map that makes it content-addressed.
211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
212pub struct BundleManifest {
213    /// Wire-format version. Always [`SCHEMA_VERSION`] today.
214    pub schema_version: u32,
215
216    /// Human-readable bundle name, e.g. `"yah-marketing"`. Not part of the
217    /// content-address surface beyond the digest — two bundles with the same
218    /// bytes but different names have different digests.
219    pub name: String,
220
221    /// Runtime selection (`"self"` / `"mesofact/<ver>"`).
222    pub runtime: BundleRuntime,
223
224    /// Every file the bundle carries, keyed by its path *within the bundle*
225    /// (e.g. `"app/dist/index.html"`, `"bins/x86_64-unknown-linux-musl/serve"`)
226    /// mapped to its BLAKE3 content hash. Sorted (BTreeMap) so the manifest
227    /// serialization — and therefore the digest — is deterministic.
228    #[serde(default)]
229    pub content: BTreeMap<String, BundleHash>,
230}
231
232impl BundleManifest {
233    /// Parse a `manifest.toml` string, rejecting an unknown schema version.
234    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    /// Serialize back to `manifest.toml` form.
246    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    /// The bundle's content-address: a BLAKE3 digest over the manifest's
251    /// identity + content map.
252    ///
253    /// Computed over a canonical, order-stable byte encoding (not the TOML text,
254    /// which whitespace/comments could perturb): `schema_version`, `name`, the
255    /// runtime wire string, then every `(path, hash)` pair in sorted path order.
256    /// Any change to any file's bytes (its hash), the name, the runtime, or the
257    /// file set flips the digest — that immutability is what makes rollback a
258    /// pointer flip and eviction a no-invalidation affair (W272 §1).
259    pub fn digest(&self) -> BundleHash {
260        let mut hasher = blake3::Hasher::new();
261        // Domain-separate + length-prefix every field so no two distinct
262        // manifests can collide by field-boundary ambiguity.
263        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        // BTreeMap iterates in sorted key order — deterministic.
273        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
281/// Object-store key for a content blob: `blobs/<blake3>`. Every file across
282/// every bundle dedupes on this key — two bundles sharing a byte-identical file
283/// share the one blob.
284pub fn blob_key(hash: &BundleHash) -> String {
285    format!("blobs/{}", hash.as_str())
286}
287
288/// Object-store key for a bundle manifest: `manifests/<digest>`. Immutable —
289/// the digest is derived from the manifest, so re-publishing an identical
290/// bundle writes the same key with the same bytes.
291pub 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()); // hex, gets lowercased
323        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        // Malformed runtimes reject.
339        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        // Insertion order differs but BTreeMap normalizes — same digest.
385        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        // Different file bytes (hash) → different digest.
402        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        // Different name → different digest.
407        assert_ne!(BundleManifest { name: "other".into(), ..sample() }.digest(), d0);
408
409        // Different runtime → different digest.
410        assert_ne!(
411            BundleManifest { runtime: BundleRuntime::SelfContained, ..sample() }.digest(),
412            d0
413        );
414
415        // Extra file → different digest.
416        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}