Skip to main content

yah_mesofact_bundle/
assemble.rs

1//! Assemble a built mesofact app into a W272 **bundle** — the content-addressed
2//! unit the store publishes and a node materializes.
3//!
4//! Part of R599-F2, moved here from `mesofact-build` under R599-T5. It lived in
5//! the mesofact-build subcamp originally, but that crate pulls rolldown +
6//! lightningcss + deno_core (V8), so anything linking it inherits the whole
7//! build toolchain. The operator driver (`yah cloud bundle`) needs to *emit* a
8//! bundle without being able to *build* one, and the yah CLI is deliberately
9//! V8-less (R490-F2). Assembly is pure `std::fs` + blake3 + toml, so it belongs
10//! next to the manifest types it writes; `mesofact-build` re-exports from here
11//! so there is exactly one implementation and no drift.
12//!
13//! A vanilla bundle's tree (W272 §1):
14//!
15//! ```text
16//! <bundle>/
17//!   manifest.toml
18//!   app/
19//!     mesofact.routes.ts        # routes/config
20//!     mesofact.config.toml      # [publish] block for a --revalidate receiver (if present)
21//!     src/data/…                # each route's declared `data_inputs` (see below)
22//!     dist/…                    # built TS + rendered assets (the build out_dir)
23//! ```
24//!
25//! `data_inputs` are staged because a `--revalidate` receiver **re-reads them
26//! at poke time** — `mesofact-render`'s `read_data_inputs` resolves each
27//! declared path against the workload root (`<bundle>/app`), not against the
28//! machine that ran the build. A bundle carrying only `dist/` renders fine as
29//! static but fails the moment it is poked, which is the one failure mode a
30//! receiver deploy exists to avoid (R330-F12).
31//!
32//! A custom (`runtime = "self"`) bundle additionally carries
33//! `bins/<triple>/serve`. The only shape difference between the two is whether
34//! `bins/` is present and what `runtime` names — there is one format.
35
36use std::collections::BTreeMap;
37use std::fs;
38use std::path::{Path, PathBuf};
39
40use crate::{BundleError, BundleHash, BundleManifest, BundleRuntime, SCHEMA_VERSION};
41
42/// Wrap an I/O failure with the path that caused it. Bare `io::Error` loses the
43/// filename, which is the only thing that makes an assembly failure actionable.
44fn io(context: impl AsRef<str>, e: std::io::Error) -> BundleError {
45    BundleError::Io(format!("{}: {e}", context.as_ref()))
46}
47
48/// One file destined for a bundle: `(path within the bundle, source on disk)`.
49/// The bundle path is always relative and forward-slashed, e.g.
50/// `"app/dist/index.html"` or `"bins/x86_64-unknown-linux-musl/serve"`.
51pub type BundleFile = (String, PathBuf);
52
53/// Where one `serve_bins` entry comes from (R746-F5).
54#[derive(Debug, Clone)]
55pub enum ServeBinSource {
56    /// A binary on this machine's disk — copied into the bundle tree and
57    /// hashed like any other file.
58    Local(PathBuf),
59    /// A binary the store already holds, referenced by its BLAKE3 digest.
60    /// Nothing is read from local disk and nothing is copied into the bundle
61    /// tree; the manifest's `content` map simply names this hash, and
62    /// `publish_bundle` skips it because `store.head` already finds the blob
63    /// (the same append-only dedupe every other file gets). This is the
64    /// mechanism that makes a template-only re-release possible on a machine
65    /// that has never built the binary at all.
66    Pinned(BundleHash),
67}
68
69/// Recursively enumerate the regular files under `dir`, mapping each to a
70/// bundle path `"<prefix>/<relative-path>"`. Results are sorted for determinism.
71pub fn collect_dir(prefix: &str, dir: &Path) -> Result<Vec<BundleFile>, BundleError> {
72    let mut out = Vec::new();
73    let mut stack = vec![dir.to_path_buf()];
74    while let Some(d) = stack.pop() {
75        let entries = fs::read_dir(&d).map_err(|e| io(format!("reading {}", d.display()), e))?;
76        for entry in entries {
77            let entry = entry.map_err(|e| io(format!("reading {}", d.display()), e))?;
78            let ft = entry
79                .file_type()
80                .map_err(|e| io(format!("stat {}", entry.path().display()), e))?;
81            let p = entry.path();
82            if ft.is_dir() {
83                stack.push(p);
84            } else if ft.is_file() {
85                let rel = p.strip_prefix(dir).expect("walker yields paths under dir");
86                let rel_str = rel.to_string_lossy().replace('\\', "/");
87                out.push((format!("{prefix}/{rel_str}"), p));
88            }
89        }
90    }
91    out.sort();
92    Ok(out)
93}
94
95/// Assemble a bundle tree at `dest` from `files`, returning the [`BundleManifest`].
96///
97/// Copies each source file to `<dest>/<bundle-path>`, hashes it (BLAKE3) into the
98/// manifest `content` map, and writes `<dest>/manifest.toml` last. Files under
99/// `bins/` are made executable (0755) — they are serve binaries. Re-assembling
100/// over an existing `dest` overwrites in place (idempotent). `dest` MUST NOT sit
101/// inside any of the source dirs being collected, or the copy would recurse into
102/// its own output.
103pub fn assemble_bundle(
104    dest: &Path,
105    name: &str,
106    runtime: BundleRuntime,
107    files: &[BundleFile],
108) -> Result<BundleManifest, BundleError> {
109    fs::create_dir_all(dest).map_err(|e| io(format!("creating bundle dir {}", dest.display()), e))?;
110
111    let mut content = BTreeMap::new();
112    for (bundle_path, src) in files {
113        let bytes = fs::read(src).map_err(|e| io(format!("reading {}", src.display()), e))?;
114        let hash = BundleHash::of(&bytes);
115        let out = dest.join(bundle_path);
116        if let Some(parent) = out.parent() {
117            fs::create_dir_all(parent)
118                .map_err(|e| io(format!("creating {}", parent.display()), e))?;
119        }
120        fs::write(&out, &bytes).map_err(|e| io(format!("writing {}", out.display()), e))?;
121
122        // Serve binaries must stay executable after the copy.
123        #[cfg(unix)]
124        if bundle_path.starts_with("bins/") {
125            use std::os::unix::fs::PermissionsExt;
126            let mut perm = fs::metadata(&out)
127                .map_err(|e| io(format!("stat {}", out.display()), e))?
128                .permissions();
129            perm.set_mode(0o755);
130            fs::set_permissions(&out, perm)
131                .map_err(|e| io(format!("chmod +x {}", out.display()), e))?;
132        }
133
134        content.insert(bundle_path.clone(), hash);
135    }
136
137    // Every shape (vanilla, self-contained, hand-driven) funnels through here,
138    // so this is the single site that decides what contract a bundle assembled
139    // by this build requires — there is no per-shape override and no way for a
140    // caller to under-declare it (R746-F6).
141    let manifest = BundleManifest {
142        schema_version: SCHEMA_VERSION,
143        name: name.to_string(),
144        runtime,
145        requires_contract: crate::BUNDLE_CONTRACT_VERSION,
146        content,
147    };
148    let toml = manifest.to_toml_string()?;
149    fs::write(dest.join("manifest.toml"), toml).map_err(|e| {
150        io(
151            format!("writing {}", dest.join("manifest.toml").display()),
152            e,
153        )
154    })?;
155    Ok(manifest)
156}
157
158/// Assemble a **vanilla** bundle from a finished mesofact build: `app/` holds the
159/// routes file plus the built `dist/` tree, no `bins/`, and `runtime =
160/// "mesofact/<runtime_version>"` so the node resolves the stock serve runtime.
161///
162/// `out_dir` is the build's dist directory; `runtime_version` is the version of
163/// `mesofact serve` that should serve this bundle (e.g. the framework version
164/// the app was built against).
165///
166/// The node resolves that runtime from its shared runtime-asset tier
167/// ([`ensure_runtime_asset`](crate::ensure_runtime_asset), R746-F1), fetching
168/// and blake3-verifying it from the bundle store on a miss. The asset must have
169/// been published for the node's triple first (`yah cloud bundle
170/// publish-runtime`) — a bundle naming a runtime nothing published fails the
171/// deploy, loudly and without falling back to any other binary on the box.
172pub fn assemble_vanilla_bundle(
173    dest: &Path,
174    name: &str,
175    runtime_version: &str,
176    project_root: &Path,
177    out_dir: &Path,
178) -> Result<BundleManifest, BundleError> {
179    crate::assets::finalize_component_output(project_root, out_dir)?;
180    let mut files = collect_dir("app/dist", out_dir)?;
181    stage_app_config(project_root, &mut files);
182    stage_data_inputs(project_root, out_dir, &mut files)?;
183    files.sort();
184    assemble_bundle(
185        dest,
186        name,
187        BundleRuntime::Mesofact {
188            version: runtime_version.to_string(),
189        },
190        &files,
191    )
192}
193
194/// Assemble a **self-contained** (`runtime = "self"`) bundle: the vanilla tree
195/// plus `bins/<triple>/serve`, so the node serves it with the binary the bundle
196/// carries and resolves no runtime asset at all.
197///
198/// Reach for this when the app needs a serve binary that is not a stock build —
199/// a custom SSR runtime, an app-specific patch. It is closed over its own
200/// binary, so it works against a node that has never fetched a runtime asset,
201/// at the cost of shipping ~70MB per site per release. R746-F1 made vanilla the
202/// cheaper default for everything that runs the stock runtime.
203pub fn assemble_self_bundle(
204    dest: &Path,
205    name: &str,
206    project_root: &Path,
207    out_dir: &Path,
208    serve_bins: &[(String, PathBuf)],
209) -> Result<BundleManifest, BundleError> {
210    let serve_bins: Vec<(String, ServeBinSource)> = serve_bins
211        .iter()
212        .map(|(triple, path)| (triple.clone(), ServeBinSource::Local(path.clone())))
213        .collect();
214    assemble_self_bundle_with(dest, name, project_root, out_dir, &serve_bins, &[], None)
215}
216
217/// (Was: "the shape to reach for today" — that stopped being true when R746-F1
218/// landed node-side runtime resolution. Both shapes serve; vanilla is the one
219/// that shares a ~70MB binary across every site on a node.)
220///
221/// [`assemble_self_bundle`] plus **sidecar binaries** — extra executables the
222/// node forks alongside `serve`, staged at `bins/<triple>/<name>` (R330-F31).
223///
224/// The motivating case is `almanac-feed`, the feed-fetch tier: a bundle whose
225/// routes render from a live feed needs something on the node to refresh that
226/// feed's artifact, and the node has no package manager. Shipping the fetcher
227/// inside the bundle keeps a served bundle closed over everything it needs, the
228/// same property that makes `runtime = "self"` the shape to reach for.
229///
230/// `sidecar_bins` entries are `(binary name, target triple, source path)`.
231///
232/// `serve_bins` entries are `(triple, source)` where source is either a local
233/// file to copy in ([`ServeBinSource::Local`]) or a digest already published
234/// to the store from a prior assembly ([`ServeBinSource::Pinned`], R746-F5) —
235/// see that variant's docs for why a pinned entry costs no local read and no
236/// re-upload.
237///
238/// `built_against` is the reuse contract recorded on the manifest
239/// ([`BundleRuntime::SelfContained::built_against`]): the fingerprint every
240/// `Local` binary in this call was built from. Pass `None` for a one-shot
241/// assembly with nothing to record (e.g. `yah cloud bundle build`); the
242/// resulting bundle still serves, it just can't be a reuse source later.
243pub fn assemble_self_bundle_with(
244    dest: &Path,
245    name: &str,
246    project_root: &Path,
247    out_dir: &Path,
248    serve_bins: &[(String, ServeBinSource)],
249    sidecar_bins: &[(String, String, PathBuf)],
250    built_against: Option<String>,
251) -> Result<BundleManifest, BundleError> {
252    crate::assets::finalize_component_output(project_root, out_dir)?;
253    let mut files = collect_dir("app/dist", out_dir)?;
254    stage_app_config(project_root, &mut files);
255    stage_data_inputs(project_root, out_dir, &mut files)?;
256    let mut pinned: Vec<(String, BundleHash)> = Vec::new();
257    for (triple, source) in serve_bins {
258        let bundle_path = format!("bins/{triple}/serve");
259        match source {
260            ServeBinSource::Local(bin) => files.push((bundle_path, bin.clone())),
261            ServeBinSource::Pinned(hash) => pinned.push((bundle_path, hash.clone())),
262        }
263    }
264    for (bin_name, triple, bin) in sidecar_bins {
265        // `serve` is the runtime's own reserved name — a sidecar claiming it
266        // would silently replace the server with something that isn't one.
267        if bin_name == "serve" {
268            return Err(BundleError::Io(format!(
269                "sidecar bin for {triple} is named \"serve\", which is the runtime's own \
270                 binary — pick a distinct name"
271            )));
272        }
273        files.push((format!("bins/{triple}/{bin_name}"), bin.clone()));
274    }
275    files.sort();
276    let mut manifest = assemble_bundle(
277        dest,
278        name,
279        BundleRuntime::SelfContained { built_against },
280        &files,
281    )?;
282    if !pinned.is_empty() {
283        for (path, hash) in pinned {
284            manifest.content.insert(path, hash);
285        }
286        // assemble_bundle already wrote manifest.toml without the pinned
287        // entries (they never went through its file loop) — rewrite it now
288        // that content carries them, since the manifest object on disk must
289        // match what publish_bundle reads.
290        let toml = manifest.to_toml_string()?;
291        fs::write(dest.join("manifest.toml"), toml)
292            .map_err(|e| io(format!("writing {}", dest.join("manifest.toml").display()), e))?;
293    }
294    Ok(manifest)
295}
296
297/// Assemble a bundle from ALREADY-COLLECTED content files — the multi-component
298/// counterpart to [`assemble_self_bundle_with`]/[`assemble_vanilla_bundle`],
299/// which each collect `files` themselves from a single `(project_root,
300/// out_dir)` pair. R870-B11: a service with several mounted components merges
301/// each component's [`collect_component_files`] output into one `files` list
302/// before calling this, so the whole service becomes ONE bundle instead of one
303/// per component — the defect this exists to fix (two components silently
304/// overwriting the same deployed workload, one bundle at a time).
305///
306/// Picks vanilla vs self-contained the same way the single-component path
307/// does: no `serve_bins` names the stock `mesofact/<runtime_version>` runtime;
308/// any `serve_bins` makes it self-contained (`runtime = "self"`).
309pub fn assemble_bundle_from_files(
310    dest: &Path,
311    name: &str,
312    runtime_version: &str,
313    mut files: Vec<BundleFile>,
314    serve_bins: &[(String, ServeBinSource)],
315    sidecar_bins: &[(String, String, PathBuf)],
316    built_against: Option<String>,
317) -> Result<BundleManifest, BundleError> {
318    if serve_bins.is_empty() {
319        files.sort();
320        return assemble_bundle(
321            dest,
322            name,
323            BundleRuntime::Mesofact {
324                version: runtime_version.to_string(),
325            },
326            &files,
327        );
328    }
329
330    let mut pinned: Vec<(String, BundleHash)> = Vec::new();
331    for (triple, source) in serve_bins {
332        let bundle_path = format!("bins/{triple}/serve");
333        match source {
334            ServeBinSource::Local(bin) => files.push((bundle_path, bin.clone())),
335            ServeBinSource::Pinned(hash) => pinned.push((bundle_path, hash.clone())),
336        }
337    }
338    for (bin_name, triple, bin) in sidecar_bins {
339        if bin_name == "serve" {
340            return Err(BundleError::Io(format!(
341                "sidecar bin for {triple} is named \"serve\", which is the runtime's own \
342                 binary — pick a distinct name"
343            )));
344        }
345        files.push((format!("bins/{triple}/{bin_name}"), bin.clone()));
346    }
347    files.sort();
348    let mut manifest = assemble_bundle(
349        dest,
350        name,
351        BundleRuntime::SelfContained { built_against },
352        &files,
353    )?;
354    if !pinned.is_empty() {
355        for (path, hash) in pinned {
356            manifest.content.insert(path, hash);
357        }
358        // assemble_bundle already wrote manifest.toml without the pinned
359        // entries (they never went through its file loop) — rewrite it now
360        // that content carries them, since the manifest object on disk must
361        // match what publish_bundle reads.
362        let toml = manifest.to_toml_string()?;
363        fs::write(dest.join("manifest.toml"), toml)
364            .map_err(|e| io(format!("writing {}", dest.join("manifest.toml").display()), e))?;
365    }
366    Ok(manifest)
367}
368
369/// Collect the files for a single component's bundle entry, with an optional
370/// mount prefix for the `dist/` tree.
371///
372/// `mount` is the normalized mount path (e.g., `"app"` for `/app`). When
373/// `include_config` is true, `mesofact.routes.ts`, `mesofact.config.toml`, and
374/// declared `data_inputs` are included — this should be true for the primary
375/// component and false for mounted components in a service bundle (R870-B11).
376///
377/// A mounted component stages **inside the served root**, at
378/// `app/dist/html/<mount>/`, not beside it at `app/dist/<mount>/`. The served
379/// root is a server-side constant, not a build-output coincidence:
380/// `Server::from_bundle` points `from_workload` at `<bundle>/app`, and
381/// `from_workload` roots static serving at `<workload>/dist/html`. So a file
382/// staged at `app/dist/html/app/index.html` is reachable at `/app/index.html`
383/// and one staged at `app/dist/app/index.html` is reachable at no URL at all
384/// — which is how R870-B11 shipped a merged bundle whose mounted half 404'd
385/// on every path. The unmounted primary needs no `html/` here because its own
386/// `out_dir` already contains one: a mesofact-spa/static build emits
387/// `dist/html/**`, and that tree lands under `app/dist/`.
388///
389/// Finalizes `out_dir` in place first ([`crate::assets::finalize_component_output`]):
390/// the component's asset index and precompressed variants are written into
391/// its own output, so they stage at the component's mount with everything
392/// else and `mesofact serve` finds them by walking up from a file.
393pub fn collect_component_files(
394    project_root: &Path,
395    out_dir: &Path,
396    mount: Option<&str>,
397    include_config: bool,
398) -> Result<Vec<BundleFile>, BundleError> {
399    crate::assets::finalize_component_output(project_root, out_dir)?;
400    let dist_prefix = match mount {
401        Some(m) if !m.is_empty() => format!("app/dist/html/{m}"),
402        _ => "app/dist".to_string(),
403    };
404    let mut files = collect_dir(&dist_prefix, out_dir)?;
405    if include_config {
406        stage_app_config(project_root, &mut files);
407        stage_data_inputs(project_root, out_dir, &mut files)?;
408    }
409    Ok(files)
410}
411
412/// Stage the project-root config files a served bundle carries alongside its
413/// `dist/` tree, when present: `mesofact.routes.ts` (routes/config the runtime
414/// reads) and `mesofact.config.toml` (the `[publish]` block a `mesofact serve
415/// --revalidate` receiver needs, R330-F12 — creds still resolve from env, so
416/// only the non-secret bucket/prefix/endpoint/zone travels here). Both land
417/// under `app/` so a receiver pointed at `<bundle>/app` finds them at the same
418/// relative paths a source workload uses.
419fn stage_app_config(project_root: &Path, files: &mut Vec<BundleFile>) {
420    for name in ["mesofact.routes.ts", "mesofact.config.toml"] {
421        let src = project_root.join(name);
422        if src.exists() {
423            files.push((format!("app/{name}"), src));
424        }
425    }
426}
427
428/// Stage every `data_inputs` file the built manifest declares, so a
429/// `--revalidate` receiver can re-read them on the node (see the module docs).
430///
431/// Paths come from `<out_dir>/manifest.json` and are relative to
432/// `project_root`, which is exactly how `mesofact-render` resolves them against
433/// the workload root — so staging them at `app/<rel>` reproduces the build-time
434/// layout on the node.
435///
436/// A missing `manifest.json` is not an error: `assemble_bundle` is also driven
437/// directly (`yah cloud bundle build` over a pre-built tree, tests) where there
438/// is nothing to read. A manifest that *declares* an input we cannot stage IS
439/// an error — silently dropping it turns a deploy-time failure into a
440/// poke-time one, months later, on the node.
441fn stage_data_inputs(
442    project_root: &Path,
443    out_dir: &Path,
444    files: &mut Vec<BundleFile>,
445) -> Result<(), BundleError> {
446    /// The sliver of the mesofact build manifest this crate needs. Deliberately
447    /// not `mesofact_core::manifest::Manifest` — that type lives in the
448    /// mesofact subcamp and pulls the render-side vocabulary with it, and this
449    /// crate is the V8-less assembly half (see the module docs).
450    #[derive(serde::Deserialize)]
451    struct ManifestSliver {
452        #[serde(default)]
453        routes: Vec<RouteSliver>,
454    }
455    #[derive(serde::Deserialize)]
456    struct RouteSliver {
457        #[serde(default)]
458        data_inputs: Vec<String>,
459    }
460
461    let manifest_path = out_dir.join("manifest.json");
462    let Ok(raw) = fs::read_to_string(&manifest_path) else {
463        return Ok(());
464    };
465    let manifest: ManifestSliver = serde_json::from_str(&raw)
466        .map_err(|e| BundleError::Io(format!("parsing {}: {e}", manifest_path.display())))?;
467
468    let mut seen = std::collections::BTreeSet::new();
469    for rel in manifest.routes.into_iter().flat_map(|r| r.data_inputs) {
470        // A declared input must stay inside `app/`. `..` would land the copy
471        // outside the bundle root and, worse, resolve to something different on
472        // the node than it did at build time.
473        if Path::new(&rel)
474            .components()
475            .any(|c| matches!(c, std::path::Component::ParentDir))
476            || Path::new(&rel).is_absolute()
477        {
478            return Err(BundleError::Io(format!(
479                "data_inputs entry {rel:?} escapes the app root; declare it as a path under the project"
480            )));
481        }
482        if !seen.insert(rel.clone()) {
483            continue; // routes commonly share one data file
484        }
485        let src = project_root.join(&rel);
486        if !src.is_file() {
487            return Err(BundleError::Io(format!(
488                "manifest declares data_inputs {rel:?} but {} is not a file — a --revalidate receiver would fail to re-render this route on the node",
489                src.display()
490            )));
491        }
492        files.push((format!("app/{}", rel.replace('\\', "/")), src));
493    }
494    Ok(())
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500    use tempfile::TempDir;
501
502    fn write(path: &Path, bytes: &[u8]) {
503        fs::create_dir_all(path.parent().unwrap()).unwrap();
504        fs::write(path, bytes).unwrap();
505    }
506
507    #[test]
508    fn collect_dir_walks_recursively_and_prefixes() {
509        let dir = TempDir::new().unwrap();
510        write(&dir.path().join("index.html"), b"a");
511        write(&dir.path().join("assets/main.js"), b"b");
512        write(&dir.path().join("assets/nested/x.css"), b"c");
513
514        let files = collect_dir("app/dist", dir.path()).unwrap();
515        let paths: Vec<&str> = files.iter().map(|(p, _)| p.as_str()).collect();
516        assert_eq!(
517            paths,
518            vec![
519                "app/dist/assets/main.js",
520                "app/dist/assets/nested/x.css",
521                "app/dist/index.html",
522            ]
523        );
524    }
525
526    /// R870-B11: a mounted component must land *under* the served root, not
527    /// beside it. The oracle here is deliberately not this function's own path
528    /// construction — it is `Server::from_workload`'s `<workload>/dist/html`,
529    /// restated as the literal prefix a URL-reachable file has to carry.
530    #[test]
531    fn a_mounted_component_stages_inside_the_served_root() {
532        const SERVED_ROOT: &str = "app/dist/html/";
533
534        let dir = TempDir::new().unwrap();
535        write(&dir.path().join("index.html"), b"<app>");
536        write(&dir.path().join("app-abc123_bg.wasm"), b"\0asm");
537
538        let files =
539            collect_component_files(dir.path(), dir.path(), Some("app"), false).unwrap();
540        let paths: Vec<&str> = files.iter().map(|(p, _)| p.as_str()).collect();
541        // The component's asset index (MFT-R825-F1) stages at its mount with
542        // everything else, which is what lets `mesofact serve` find it by
543        // walking up from a file without knowing mounts exist.
544        assert_eq!(
545            paths,
546            vec![
547                "app/dist/html/app/.mesofact-assets.json",
548                "app/dist/html/app/app-abc123_bg.wasm",
549                "app/dist/html/app/index.html",
550            ]
551        );
552        for (path, _) in &files {
553            assert!(
554                path.starts_with(SERVED_ROOT),
555                "{path} is staged outside the served root {SERVED_ROOT} — unreachable by any URL"
556            );
557        }
558    }
559
560    /// The unmounted primary carries no extra `html/`: its own build output
561    /// already contains one, so the served root comes from the tree it stages.
562    #[test]
563    fn an_unmounted_component_stages_its_own_html_tree_at_the_dist_root() {
564        let dir = TempDir::new().unwrap();
565        write(&dir.path().join("html/index.html"), b"<html>");
566        write(&dir.path().join("html/market.html"), b"<html>");
567
568        let files = collect_component_files(dir.path(), dir.path(), None, false).unwrap();
569        let paths: Vec<&str> = files.iter().map(|(p, _)| p.as_str()).collect();
570        assert_eq!(
571            paths,
572            vec![
573                "app/dist/html/.mesofact-assets.json",
574                "app/dist/html/index.html",
575                "app/dist/html/market.html",
576            ]
577        );
578    }
579
580    #[test]
581    fn assemble_bundle_copies_files_hashes_and_writes_manifest() {
582        let src = TempDir::new().unwrap();
583        let dest = TempDir::new().unwrap();
584        write(&src.path().join("index.html"), b"<html>home</html>");
585
586        let files = vec![("app/index.html".to_string(), src.path().join("index.html"))];
587        let manifest = assemble_bundle(
588            dest.path(),
589            "yah-marketing",
590            BundleRuntime::Mesofact {
591                version: "0.8.20".into(),
592            },
593            &files,
594        )
595        .unwrap();
596
597        // Manifest content matches the copied bytes' hash.
598        assert_eq!(
599            manifest.content.get("app/index.html").unwrap(),
600            &BundleHash::of(b"<html>home</html>")
601        );
602        // The file was copied into the bundle tree.
603        assert_eq!(
604            fs::read(dest.path().join("app/index.html")).unwrap(),
605            b"<html>home</html>"
606        );
607        // manifest.toml on disk round-trips back to the returned manifest.
608        let text = fs::read_to_string(dest.path().join("manifest.toml")).unwrap();
609        assert_eq!(BundleManifest::from_toml_str(&text).unwrap(), manifest);
610    }
611
612    #[test]
613    fn assemble_vanilla_bundle_stages_routes_and_dist() {
614        let project = TempDir::new().unwrap();
615        let out_dir = project.path().join("dist");
616        let dest = TempDir::new().unwrap();
617        write(
618            &project.path().join("mesofact.routes.ts"),
619            b"export default {}",
620        );
621        write(&out_dir.join("index.html"), b"<html>");
622        write(&out_dir.join("hydrate/app.hash.js"), b"hydrate");
623
624        let manifest =
625            assemble_vanilla_bundle(dest.path(), "yah-marketing", "0.8.20", project.path(), &out_dir)
626                .unwrap();
627
628        assert!(matches!(manifest.runtime, BundleRuntime::Mesofact { .. }));
629        assert!(manifest.content.contains_key("app/mesofact.routes.ts"));
630        assert!(manifest.content.contains_key("app/dist/index.html"));
631        assert!(manifest.content.contains_key("app/dist/hydrate/app.hash.js"));
632        // A vanilla bundle carries no serve binaries.
633        assert!(!manifest.content.keys().any(|k| k.starts_with("bins/")));
634        assert!(dest.path().join("app/mesofact.routes.ts").exists());
635    }
636
637    /// A `mesofact.config.toml` at the project root is staged into the bundle so
638    /// a `mesofact serve --revalidate` receiver on the node finds its `[publish]`
639    /// block at `<bundle>/app/mesofact.config.toml` (R330-F12). Absent → no key,
640    /// which is why the existing digest-shaped tests stay unaffected.
641    #[test]
642    fn assemble_stages_mesofact_config_when_present() {
643        let project = TempDir::new().unwrap();
644        let out_dir = project.path().join("dist");
645        let dest = TempDir::new().unwrap();
646        write(&out_dir.join("index.html"), b"<html>");
647        write(
648            &project.path().join("mesofact.config.toml"),
649            b"[publish]\nbucket = \"yah-dev\"\nprefix = \"yah-marketing/cloud\"\n",
650        );
651
652        let manifest =
653            assemble_vanilla_bundle(dest.path(), "yah-marketing", "0.8.20", project.path(), &out_dir)
654                .unwrap();
655
656        assert!(manifest.content.contains_key("app/mesofact.config.toml"));
657        assert_eq!(
658            fs::read_to_string(dest.path().join("app/mesofact.config.toml")).unwrap(),
659            "[publish]\nbucket = \"yah-dev\"\nprefix = \"yah-marketing/cloud\"\n"
660        );
661    }
662
663    /// No config at the project root → no `app/mesofact.config.toml` key. Guards
664    /// the invariant that the staging is purely additive when the file is absent.
665    #[test]
666    fn assemble_omits_config_when_absent() {
667        let project = TempDir::new().unwrap();
668        let out_dir = project.path().join("dist");
669        let dest = TempDir::new().unwrap();
670        write(&out_dir.join("index.html"), b"<html>");
671
672        let manifest =
673            assemble_vanilla_bundle(dest.path(), "yah-marketing", "0.8.20", project.path(), &out_dir)
674                .unwrap();
675
676        assert!(!manifest.content.contains_key("app/mesofact.config.toml"));
677    }
678
679    /// R330-F12: the build manifest's declared `data_inputs` are staged at
680    /// `app/<rel>` — the same path the receiver re-reads them from on the node.
681    /// Two routes sharing one file stage it once.
682    #[test]
683    fn assemble_stages_declared_data_inputs_once_each() {
684        let project = TempDir::new().unwrap();
685        let out_dir = project.path().join("dist");
686        let dest = TempDir::new().unwrap();
687        write(&out_dir.join("index.html"), b"<html>");
688        write(
689            &out_dir.join("manifest.json"),
690            br#"{"routes":[
691                 {"route":"/releases","data_inputs":["src/data/releases.json"]},
692                 {"route":"/issues","data_inputs":["src/data/issues.json"]},
693                 {"route":"/issues/:id","data_inputs":["src/data/issues.json"]},
694                 {"route":"/"}
695               ]}"#,
696        );
697        write(
698            &project.path().join("src/data/releases.json"),
699            br#"{"releases":[]}"#,
700        );
701        write(&project.path().join("src/data/issues.json"), br#"{"items":[]}"#);
702
703        let manifest =
704            assemble_vanilla_bundle(dest.path(), "yah-marketing", "0.8.20", project.path(), &out_dir)
705                .unwrap();
706
707        assert!(manifest.content.contains_key("app/src/data/releases.json"));
708        assert!(manifest.content.contains_key("app/src/data/issues.json"));
709        assert_eq!(
710            fs::read_to_string(dest.path().join("app/src/data/releases.json")).unwrap(),
711            r#"{"releases":[]}"#
712        );
713        // Deduped: the shared issues.json appears exactly once in `content`
714        // (a BTreeMap would hide a duplicate — count the staged keys instead).
715        assert_eq!(
716            manifest
717                .content
718                .keys()
719                .filter(|k| k.as_str() == "app/src/data/issues.json")
720                .count(),
721            1
722        );
723    }
724
725    /// A declared-but-missing `data_inputs` file fails the *assembly*, not the
726    /// first poke months later on the node. This is the whole point of staging
727    /// them from the manifest rather than best-effort globbing `src/data`.
728    #[test]
729    fn assemble_rejects_declared_data_input_that_is_not_on_disk() {
730        let project = TempDir::new().unwrap();
731        let out_dir = project.path().join("dist");
732        let dest = TempDir::new().unwrap();
733        write(&out_dir.join("index.html"), b"<html>");
734        write(
735            &out_dir.join("manifest.json"),
736            br#"{"routes":[{"route":"/releases","data_inputs":["src/data/releases.json"]}]}"#,
737        );
738
739        let err = assemble_vanilla_bundle(
740            dest.path(),
741            "yah-marketing",
742            "0.8.20",
743            project.path(),
744            &out_dir,
745        )
746        .unwrap_err();
747        let msg = err.to_string();
748        assert!(msg.contains("src/data/releases.json"), "got {msg}");
749        assert!(msg.contains("revalidate"), "got {msg}");
750    }
751
752    /// A `data_inputs` path that climbs out of the project root is rejected —
753    /// it would copy to a bundle path outside `app/` and resolve differently on
754    /// the node than it did at build time.
755    #[test]
756    fn assemble_rejects_data_input_escaping_the_app_root() {
757        let project = TempDir::new().unwrap();
758        let out_dir = project.path().join("dist");
759        let dest = TempDir::new().unwrap();
760        write(&out_dir.join("index.html"), b"<html>");
761        write(
762            &out_dir.join("manifest.json"),
763            br#"{"routes":[{"route":"/x","data_inputs":["../secrets.json"]}]}"#,
764        );
765
766        let err = assemble_vanilla_bundle(
767            dest.path(),
768            "yah-marketing",
769            "0.8.20",
770            project.path(),
771            &out_dir,
772        )
773        .unwrap_err();
774        assert!(err.to_string().contains("escapes the app root"), "got {err}");
775    }
776
777    /// No `manifest.json` in the out dir (assembly driven over a pre-built tree,
778    /// or the existing fixtures) → staging is a no-op, not a failure.
779    #[test]
780    fn assemble_without_a_build_manifest_stages_no_data_inputs() {
781        let project = TempDir::new().unwrap();
782        let out_dir = project.path().join("dist");
783        let dest = TempDir::new().unwrap();
784        write(&out_dir.join("index.html"), b"<html>");
785
786        let manifest =
787            assemble_vanilla_bundle(dest.path(), "yah-marketing", "0.8.20", project.path(), &out_dir)
788                .unwrap();
789
790        assert!(!manifest.content.keys().any(|k| k.starts_with("app/src/")));
791    }
792
793    #[test]
794    fn assemble_self_bundle_carries_its_serve_binary() {
795        let project = TempDir::new().unwrap();
796        let out_dir = project.path().join("dist");
797        let dest = TempDir::new().unwrap();
798        let bin = project.path().join("serve");
799        write(&out_dir.join("index.html"), b"<html>");
800        write(&bin, b"\x7fELF-ish");
801
802        let manifest = assemble_self_bundle(
803            dest.path(),
804            "yah-marketing",
805            project.path(),
806            &out_dir,
807            &[("x86_64-unknown-linux-musl".to_string(), bin)],
808        )
809        .unwrap();
810
811        assert!(matches!(manifest.runtime, BundleRuntime::SelfContained { built_against: None }));
812        assert!(manifest
813            .content
814            .contains_key("bins/x86_64-unknown-linux-musl/serve"));
815        assert!(manifest.content.contains_key("app/dist/index.html"));
816        // The wire form is what kamaji matches on to skip runtime resolution.
817        assert_eq!(manifest.runtime.as_wire(), "self");
818    }
819
820    /// R746-F5: a `Pinned` serve bin costs no local read — the source path
821    /// doesn't even need to exist — and the recorded hash lands verbatim in
822    /// the manifest, with `built_against` carrying the reuse contract.
823    #[test]
824    fn assemble_self_bundle_with_pins_a_serve_bin_by_digest() {
825        let project = TempDir::new().unwrap();
826        let out_dir = project.path().join("dist");
827        let dest = TempDir::new().unwrap();
828        write(&out_dir.join("index.html"), b"<html>");
829        let pinned_hash = BundleHash::of(b"a previously published serve binary");
830
831        let manifest = assemble_self_bundle_with(
832            dest.path(),
833            "yah-marketing",
834            project.path(),
835            &out_dir,
836            &[(
837                "x86_64-unknown-linux-musl".to_string(),
838                ServeBinSource::Pinned(pinned_hash.clone()),
839            )],
840            &[],
841            Some("fp-abc123".to_string()),
842        )
843        .unwrap();
844
845        assert_eq!(
846            manifest.content.get("bins/x86_64-unknown-linux-musl/serve"),
847            Some(&pinned_hash)
848        );
849        // No bytes were ever copied in for a pinned entry.
850        assert!(!dest.path().join("bins/x86_64-unknown-linux-musl/serve").exists());
851        assert_eq!(
852            manifest.runtime,
853            BundleRuntime::SelfContained {
854                built_against: Some("fp-abc123".to_string())
855            }
856        );
857        // manifest.toml on disk carries the pinned entry too (it's rewritten
858        // after assemble_bundle's first pass, which never saw it).
859        let text = fs::read_to_string(dest.path().join("manifest.toml")).unwrap();
860        assert_eq!(BundleManifest::from_toml_str(&text).unwrap(), manifest);
861    }
862
863    /// A bundle can mix a pinned triple with a freshly built one — the common
864    /// case when only some triples' fingerprints still match.
865    #[test]
866    fn assemble_self_bundle_with_mixes_pinned_and_local_bins() {
867        let project = TempDir::new().unwrap();
868        let out_dir = project.path().join("dist");
869        let dest = TempDir::new().unwrap();
870        let local_bin = project.path().join("serve-aarch64");
871        write(&out_dir.join("index.html"), b"<html>");
872        write(&local_bin, b"\x7fELF-aarch64");
873        let pinned_hash = BundleHash::of(b"already published x86_64 serve");
874
875        let manifest = assemble_self_bundle_with(
876            dest.path(),
877            "yah-marketing",
878            project.path(),
879            &out_dir,
880            &[
881                (
882                    "x86_64-unknown-linux-musl".to_string(),
883                    ServeBinSource::Pinned(pinned_hash.clone()),
884                ),
885                (
886                    "aarch64-unknown-linux-musl".to_string(),
887                    ServeBinSource::Local(local_bin),
888                ),
889            ],
890            &[],
891            None,
892        )
893        .unwrap();
894
895        assert_eq!(
896            manifest.content.get("bins/x86_64-unknown-linux-musl/serve"),
897            Some(&pinned_hash)
898        );
899        assert!(dest.path().join("bins/aarch64-unknown-linux-musl/serve").exists());
900        assert!(!dest.path().join("bins/x86_64-unknown-linux-musl/serve").exists());
901    }
902
903    /// R330-F31: a sidecar bin lands at `bins/<triple>/<name>` next to `serve`,
904    /// so a bundle that renders from a live feed carries the fetcher that keeps
905    /// that feed fresh on the node.
906    #[test]
907    fn assemble_self_bundle_with_carries_sidecar_bins() {
908        let project = TempDir::new().unwrap();
909        let out_dir = project.path().join("dist");
910        let dest = TempDir::new().unwrap();
911        let serve = project.path().join("serve");
912        let feed = project.path().join("almanac-feed");
913        write(&out_dir.join("index.html"), b"<html>");
914        write(&serve, b"\x7fELF-serve");
915        write(&feed, b"\x7fELF-feed");
916
917        let manifest = assemble_self_bundle_with(
918            dest.path(),
919            "yah-marketing",
920            project.path(),
921            &out_dir,
922            &[(
923                "x86_64-unknown-linux-musl".to_string(),
924                ServeBinSource::Local(serve),
925            )],
926            &[(
927                "almanac-feed".to_string(),
928                "x86_64-unknown-linux-musl".to_string(),
929                feed,
930            )],
931            None,
932        )
933        .unwrap();
934
935        assert!(manifest
936            .content
937            .contains_key("bins/x86_64-unknown-linux-musl/serve"));
938        assert!(manifest
939            .content
940            .contains_key("bins/x86_64-unknown-linux-musl/almanac-feed"));
941    }
942
943    /// A sidecar may not be called `serve` — it would land on the runtime's own
944    /// path and the node would fork it as the server.
945    #[test]
946    fn assemble_rejects_a_sidecar_named_serve() {
947        let project = TempDir::new().unwrap();
948        let out_dir = project.path().join("dist");
949        let dest = TempDir::new().unwrap();
950        let bin = project.path().join("bin");
951        write(&out_dir.join("index.html"), b"<html>");
952        write(&bin, b"\x7fELF-ish");
953
954        let err = assemble_self_bundle_with(
955            dest.path(),
956            "yah-marketing",
957            project.path(),
958            &out_dir,
959            &[],
960            &[(
961                "serve".to_string(),
962                "x86_64-unknown-linux-musl".to_string(),
963                bin,
964            )],
965            None,
966        )
967        .unwrap_err();
968        assert!(err.to_string().contains("runtime's own"), "got {err}");
969    }
970
971    /// Sidecars are executables too — a staged fetcher the node cannot exec is
972    /// a deploy that looks fine and never refreshes anything.
973    #[test]
974    #[cfg(unix)]
975    fn sidecar_bins_are_executable() {
976        use std::os::unix::fs::PermissionsExt;
977        let project = TempDir::new().unwrap();
978        let out_dir = project.path().join("dist");
979        let dest = TempDir::new().unwrap();
980        let feed = project.path().join("almanac-feed");
981        write(&out_dir.join("index.html"), b"<html>");
982        write(&feed, b"\x7fELF-feed");
983
984        assemble_self_bundle_with(
985            dest.path(),
986            "yah-marketing",
987            project.path(),
988            &out_dir,
989            &[],
990            &[(
991                "almanac-feed".to_string(),
992                "x86_64-unknown-linux-musl".to_string(),
993                feed,
994            )],
995            None,
996        )
997        .unwrap();
998
999        let mode = fs::metadata(dest.path().join("bins/x86_64-unknown-linux-musl/almanac-feed"))
1000            .unwrap()
1001            .permissions()
1002            .mode();
1003        assert_eq!(mode & 0o111, 0o111, "sidecar must stay executable");
1004    }
1005
1006    #[test]
1007    #[cfg(unix)]
1008    fn bins_are_made_executable() {
1009        use std::os::unix::fs::PermissionsExt;
1010        let src = TempDir::new().unwrap();
1011        let dest = TempDir::new().unwrap();
1012        write(&src.path().join("serve"), b"\x7fELF-ish");
1013
1014        let files = vec![(
1015            "bins/x86_64-unknown-linux-musl/serve".to_string(),
1016            src.path().join("serve"),
1017        )];
1018        assemble_bundle(dest.path(), "app", BundleRuntime::self_contained(), &files).unwrap();
1019
1020        let mode = fs::metadata(dest.path().join("bins/x86_64-unknown-linux-musl/serve"))
1021            .unwrap()
1022            .permissions()
1023            .mode();
1024        assert_eq!(mode & 0o111, 0o111, "serve binary should be executable");
1025    }
1026}