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}