Skip to main content

theater_cli/commands/
compose.rs

1//! Self-contained **verification** for the packr 0.11.0 plain-build model.
2//!
3//! As of packr 0.11.0 there is no composition step: an actor is a PLAIN cargo
4//! build. `packr_guest::setup_guest!()` links the allocator (dlmalloc) into the
5//! cdylib, so the built `.wasm` already exports its own (growable) memory +
6//! `__pack_alloc`/`__pack_free` + lifecycle and imports only host
7//! `theater:simple/*` interfaces. That bare `.wasm` is what theater's loader
8//! accepts directly — no `packr::link`, no bundled allocator, no fixed-base
9//! recipe. (Historically this module linked member + `DEFAULT_ALLOCATOR_WASM`
10//! into a composite; all of that machinery was removed in 0.11.0.)
11//!
12//! What remains is the post-build **gate**: assert the built artifact is
13//! genuinely self-contained (imports only host functions), so a bad build fails
14//! the build instead of failing at boot. Uses `wasm-tools` on PATH.
15
16use anyhow::{bail, Context, Result};
17use std::path::Path;
18use std::process::Command;
19
20/// Post-build gate: assert a plain-built actor `.wasm` is genuinely
21/// self-contained, so a bad artifact fails the **build** instead of at boot.
22///
23/// The definitive structural check is the import surface: a self-contained
24/// actor imports **only** host `theater:simple/*` functions — never memory,
25/// never an allocator (`pack:alloc`). On the 0.11.0 plain-build model the
26/// allocator + memory are internal to the cdylib, so a correct build yields no
27/// offending imports; an imported memory or `pack:alloc` means the actor was
28/// built wrong (e.g. with the retired fixed-base `--import-memory` recipe).
29///
30/// Uses `wasm-tools` on PATH.
31pub fn verify_self_contained(actor_path: &Path) -> Result<()> {
32    // 1) Structural validity.
33    let validate = Command::new("wasm-tools")
34        .arg("validate")
35        .arg(actor_path)
36        .output()
37        .context("failed to run `wasm-tools validate` — is `wasm-tools` on PATH?")?;
38    if !validate.status.success() {
39        bail!(
40            "actor wasm failed `wasm-tools validate`:\n{}",
41            String::from_utf8_lossy(&validate.stderr)
42        );
43    }
44
45    // 2) Import surface: every import must be a host `theater:simple/*`
46    //    function. Anything else — an imported memory (`env`/`memory`), the
47    //    allocator (`pack:alloc`), `__linear_memory`, etc. — means the actor is
48    //    not self-contained (a bare member built with the retired --import-memory
49    //    recipe instead of the plain 0.11.0 build).
50    let printed = Command::new("wasm-tools")
51        .arg("print")
52        .arg(actor_path)
53        .output()
54        .context("failed to run `wasm-tools print` — is `wasm-tools` on PATH?")?;
55    if !printed.status.success() {
56        bail!(
57            "`wasm-tools print` failed:\n{}",
58            String::from_utf8_lossy(&printed.stderr)
59        );
60    }
61    let wat = String::from_utf8_lossy(&printed.stdout);
62    let offenders = non_host_imports(&wat);
63
64    if !offenders.is_empty() {
65        bail!(
66            "actor is NOT self-contained: found imports other than host \
67             `theater:simple/*` — memory or the allocator was not internalized \
68             (was it built plain with packr-guest 0.11.0, or did an old \
69             --import-memory member slip in?):\n  {}",
70            offenders.join("\n  ")
71        );
72    }
73
74    Ok(())
75}
76
77/// Scan `wasm-tools print` WAT output and return every import declaration whose
78/// module is not a host `theater:simple/*` interface. A self-contained actor
79/// must yield none: an imported memory (`env`/`memory`), the allocator
80/// (`pack:alloc`), `__linear_memory`, etc. all carry non-`theater:simple/`
81/// modules and so surface here.
82fn non_host_imports(wat: &str) -> Vec<String> {
83    let mut offenders = Vec::new();
84    for line in wat.lines() {
85        let l = line.trim_start();
86        // Import declarations print as `(import "<module>" "<name>" ...)`.
87        if let Some(rest) = l.strip_prefix("(import \"") {
88            let module = rest.split('"').next().unwrap_or("");
89            if !module.starts_with("theater:simple/") {
90                offenders.push(l.trim_end().to_string());
91            }
92        }
93    }
94    offenders
95}
96
97#[cfg(test)]
98mod tests {
99    use super::non_host_imports;
100
101    #[test]
102    fn accepts_only_host_imports() {
103        let wat = r#"
104        (module
105          (import "theater:simple/runtime" "log" (func (param i32 i32)))
106          (import "theater:simple/message-server-host" "register" (func (result i32)))
107          (func $f)
108        )"#;
109        assert!(non_host_imports(wat).is_empty());
110    }
111
112    #[test]
113    fn flags_imported_memory() {
114        let wat = r#"
115        (module
116          (import "env" "memory" (memory 1))
117          (import "theater:simple/runtime" "log" (func (param i32 i32)))
118        )"#;
119        let bad = non_host_imports(wat);
120        assert_eq!(bad.len(), 1);
121        assert!(bad[0].contains("\"env\""), "got: {:?}", bad);
122    }
123
124    #[test]
125    fn flags_imported_allocator() {
126        let wat = r#"(module
127          (import "pack:alloc" "alloc" (func (param i32) (result i32)))
128        )"#;
129        let bad = non_host_imports(wat);
130        assert_eq!(bad.len(), 1);
131        assert!(bad[0].contains("pack:alloc"), "got: {:?}", bad);
132    }
133
134    #[test]
135    fn ignores_non_import_lines_mentioning_import() {
136        // A comment or export that merely contains the word must not trip it.
137        let wat = r#"(module
138          (; import is a great feature ;)
139          (export "handle-send" (func 0))
140        )"#;
141        assert!(non_host_imports(wat).is_empty());
142    }
143}