Skip to main content

yosh_plugin_manager/test_host/
mod.rs

1//! In-memory host context for `yosh-plugin run` / `yosh-plugin test`.
2//!
3//! Mirrors the precedent of `metadata_extract::MetadataCtx`: a
4//! self-contained `wasmtime` store data type with an empty `WasiCtx`
5//! plus per-capability `yosh:plugin/*` import implementations backed
6//! by `TestState`. Per-capability impls live in submodules.
7//!
8//! See `docs/superpowers/specs/2026-05-12-plugin-dev-test-runner-design.md`
9//! §3 for the architectural rationale (third host context alongside
10//! `HostContext` and `MetadataCtx`).
11
12use std::collections::{HashMap, HashSet};
13use std::path::PathBuf;
14
15use wasmtime::component::ResourceTable;
16use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiView};
17
18use yosh_plugin_api::pattern::CommandPattern;
19
20pub mod commands;
21pub mod files;
22pub mod filesystem;
23pub mod io;
24pub mod variables;
25
26/// Record of one external command spawn (commands:exec). One entry
27/// per host call, in invocation order.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct ExecRecord {
30    pub program: String,
31    pub args: Vec<String>,
32    pub exit_code: i32,
33    pub stdout_len: usize,
34    pub stderr_len: usize,
35}
36
37/// In-memory state behind every TestCtx host import. The runner
38/// constructs this from CLI flags or a scenario file, then reads it
39/// back after the guest call to format results / evaluate expectations.
40///
41/// Note: the in-memory write paths intentionally skip the validation
42/// that the production host's `VarTable::set` (and similar) performs
43/// (readonly-var rejection, integer-attribute checks, etc.). Test
44/// scenarios verifying those guard paths must run against the real
45/// shell, not this harness.
46#[derive(Debug, Default, Clone)]
47pub struct TestState {
48    /// Granted capability bitmask. Same shape as `HostContext.capabilities`.
49    pub caps: u32,
50    pub vars: HashMap<String, String>,
51    pub exported: HashSet<String>,
52    pub cwd: PathBuf,
53    pub stdout: Vec<u8>,
54    pub stderr: Vec<u8>,
55    /// Virtual filesystem contents (when `sandbox_root` is `None`).
56    pub files: HashMap<PathBuf, Vec<u8>>,
57    /// If set, files:* host imports operate on the real FS scoped to
58    /// this canonicalised root. Otherwise they operate on `files`.
59    pub sandbox_root: Option<PathBuf>,
60    /// commands:exec allowlist. Empty = all denied (PatternNotAllowed).
61    pub allow_exec: Vec<CommandPattern>,
62    pub exec_log: Vec<ExecRecord>,
63    /// (key, value) pairs the plugin wrote via variables::set during
64    /// the current step. Reset by the scenario runner between steps.
65    pub set_log: Vec<(String, String)>,
66    pub export_log: Vec<(String, String)>,
67    /// (path, bytes-written) for each files::{write,append}-file call.
68    pub write_log: Vec<(PathBuf, usize)>,
69}
70
71impl TestState {
72    /// Construct a `TestState` with the given capability bitmask and
73    /// every other field at its `Default` value. Used by per-module
74    /// unit tests so each module doesn't have to repeat a local
75    /// `state_with` helper.
76    pub fn with_caps(caps: u32) -> Self {
77        TestState {
78            caps,
79            ..TestState::default()
80        }
81    }
82}
83
84/// Per-store wrapper. `state` is the shared in-memory backend; `wasi`
85/// is an empty `WasiCtx` to absorb cargo-component's transitive
86/// WASI imports (same rationale as `MetadataCtx` §Sandboxing).
87pub struct TestCtx {
88    pub state: TestState,
89    pub(crate) table: ResourceTable,
90    pub(crate) wasi: WasiCtx,
91}
92
93impl Default for TestCtx {
94    fn default() -> Self {
95        // Same rationale as MetadataCtx: no preopens, no stdio, no env.
96        // Plugins use yosh:plugin/io, not wasi:cli/stdout.
97        let wasi = WasiCtxBuilder::new().build();
98        TestCtx {
99            state: TestState::default(),
100            table: ResourceTable::new(),
101            wasi,
102        }
103    }
104}
105
106impl TestCtx {
107    /// Build from an existing TestState (set up by the CLI / scenario).
108    pub fn new(state: TestState) -> Self {
109        TestCtx {
110            state,
111            table: ResourceTable::new(),
112            wasi: WasiCtxBuilder::new().build(),
113        }
114    }
115}
116
117impl WasiView for TestCtx {
118    fn ctx(&mut self) -> &mut WasiCtx {
119        &mut self.wasi
120    }
121    fn table(&mut self) -> &mut ResourceTable {
122        &mut self.table
123    }
124}
125
126use wasmtime::Engine;
127use wasmtime::component::Linker;
128
129/// Construct a `Linker<TestCtx>` with WASI registered. Per-capability
130/// `yosh:plugin/*` imports are added by `register_imports` (Task 9).
131pub fn build_linker(engine: &Engine) -> wasmtime::Result<Linker<TestCtx>> {
132    let mut linker = Linker::<TestCtx>::new(engine);
133    register_wasi(&mut linker)?;
134    Ok(linker)
135}
136
137/// Same rationale as `metadata_extract::register_wasi`: cargo-component
138/// plugins pull in `wasi:io` / `wasi:cli` transitively. Isolation is
139/// provided by the empty `WasiCtx` in `TestCtx::default`.
140fn register_wasi(linker: &mut Linker<TestCtx>) -> wasmtime::Result<()> {
141    wasmtime_wasi::add_to_linker_sync(linker)
142}
143
144use crate::generated::yosh::plugin::commands::ExecOutput;
145use crate::generated::yosh::plugin::files::{DirEntry, FileStat};
146use crate::generated::yosh::plugin::types::{ErrorCode, IoStream};
147
148/// Register every `yosh:plugin/*` import. The per-capability host
149/// functions enforce their own capability checks; the linker
150/// unconditionally points each WIT name at its real implementation.
151pub fn register_imports(linker: &mut Linker<TestCtx>) -> wasmtime::Result<()> {
152    // variables
153    let mut vars = linker.instance("yosh:plugin/variables@0.2.1")?;
154    vars.func_wrap(
155        "get",
156        |store: wasmtime::StoreContextMut<'_, TestCtx>, (name,): (String,)| {
157            Ok::<_, wasmtime::Error>((variables::host_get(&store.data().state, &name),))
158        },
159    )?;
160    vars.func_wrap(
161        "set",
162        |mut store: wasmtime::StoreContextMut<'_, TestCtx>, (name, value): (String, String)| {
163            Ok::<_, wasmtime::Error>((variables::host_set(
164                &mut store.data_mut().state,
165                &name,
166                &value,
167            ),))
168        },
169    )?;
170    vars.func_wrap(
171        "export-env",
172        |mut store: wasmtime::StoreContextMut<'_, TestCtx>, (name, value): (String, String)| {
173            Ok::<_, wasmtime::Error>((variables::host_export_env(
174                &mut store.data_mut().state,
175                &name,
176                &value,
177            ),))
178        },
179    )?;
180
181    // filesystem
182    let mut fs = linker.instance("yosh:plugin/filesystem@0.2.1")?;
183    fs.func_wrap(
184        "cwd",
185        |store: wasmtime::StoreContextMut<'_, TestCtx>, (): ()| {
186            Ok::<_, wasmtime::Error>((filesystem::host_cwd(&store.data().state),))
187        },
188    )?;
189    fs.func_wrap(
190        "set-cwd",
191        |mut store: wasmtime::StoreContextMut<'_, TestCtx>, (path,): (String,)| {
192            Ok::<_, wasmtime::Error>(
193                (filesystem::host_set_cwd(&mut store.data_mut().state, &path),),
194            )
195        },
196    )?;
197
198    // io
199    let mut io_inst = linker.instance("yosh:plugin/io@0.2.1")?;
200    io_inst.func_wrap(
201        "write",
202        |mut store: wasmtime::StoreContextMut<'_, TestCtx>, (target, data): (IoStream, Vec<u8>)| {
203            Ok::<_, wasmtime::Error>((io::host_write(&mut store.data_mut().state, target, &data),))
204        },
205    )?;
206
207    // files
208    let mut f = linker.instance("yosh:plugin/files@0.2.1")?;
209    f.func_wrap(
210        "read-file",
211        |store: wasmtime::StoreContextMut<'_, TestCtx>, (path,): (String,)| {
212            Ok::<_, wasmtime::Error>((files::host_read_file(&store.data().state, &path),))
213        },
214    )?;
215    f.func_wrap(
216        "read-dir",
217        |store: wasmtime::StoreContextMut<'_, TestCtx>, (path,): (String,)| {
218            Ok::<_, wasmtime::Error>((files::host_read_dir(&store.data().state, &path),))
219        },
220    )?;
221    f.func_wrap(
222        "metadata",
223        |store: wasmtime::StoreContextMut<'_, TestCtx>, (path,): (String,)| {
224            Ok::<_, wasmtime::Error>((files::host_metadata(&store.data().state, &path),))
225        },
226    )?;
227    f.func_wrap(
228        "write-file",
229        |mut store: wasmtime::StoreContextMut<'_, TestCtx>, (path, data): (String, Vec<u8>)| {
230            Ok::<_, wasmtime::Error>((files::host_write_file(
231                &mut store.data_mut().state,
232                &path,
233                &data,
234            ),))
235        },
236    )?;
237    f.func_wrap(
238        "append-file",
239        |mut store: wasmtime::StoreContextMut<'_, TestCtx>, (path, data): (String, Vec<u8>)| {
240            Ok::<_, wasmtime::Error>((files::host_append_file(
241                &mut store.data_mut().state,
242                &path,
243                &data,
244            ),))
245        },
246    )?;
247    f.func_wrap(
248        "create-dir",
249        |mut store: wasmtime::StoreContextMut<'_, TestCtx>, (path, recursive): (String, bool)| {
250            Ok::<_, wasmtime::Error>((files::host_create_dir(
251                &mut store.data_mut().state,
252                &path,
253                recursive,
254            ),))
255        },
256    )?;
257    f.func_wrap(
258        "remove-file",
259        |mut store: wasmtime::StoreContextMut<'_, TestCtx>, (path,): (String,)| {
260            Ok::<_, wasmtime::Error>((files::host_remove_file(&mut store.data_mut().state, &path),))
261        },
262    )?;
263    f.func_wrap(
264        "remove-dir",
265        |mut store: wasmtime::StoreContextMut<'_, TestCtx>, (path, recursive): (String, bool)| {
266            Ok::<_, wasmtime::Error>((files::host_remove_dir(
267                &mut store.data_mut().state,
268                &path,
269                recursive,
270            ),))
271        },
272    )?;
273
274    // commands
275    let mut cmds = linker.instance("yosh:plugin/commands@0.2.1")?;
276    cmds.func_wrap(
277        "exec",
278        |mut store: wasmtime::StoreContextMut<'_, TestCtx>,
279         (program, args): (String, Vec<String>)| {
280            Ok::<_, wasmtime::Error>((commands::host_exec(
281                &mut store.data_mut().state,
282                &program,
283                &args,
284            ),))
285        },
286    )?;
287
288    // Silence unused-import warnings if a future WIT addition removes
289    // any of these constructor calls.
290    let _ = (
291        std::marker::PhantomData::<ExecOutput>,
292        std::marker::PhantomData::<DirEntry>,
293        std::marker::PhantomData::<FileStat>,
294        std::marker::PhantomData::<ErrorCode>,
295    );
296    Ok(())
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn test_state_default_is_empty() {
305        let s = TestState::default();
306        assert!(s.vars.is_empty());
307        assert!(s.exported.is_empty());
308        assert_eq!(s.cwd.as_os_str(), "");
309        assert!(s.stdout.is_empty());
310        assert!(s.stderr.is_empty());
311        assert_eq!(s.caps, 0);
312    }
313
314    #[test]
315    fn test_ctx_default_constructs() {
316        let _ctx = TestCtx::default();
317    }
318
319    #[test]
320    fn linker_construction_smoke() {
321        let engine = crate::precompile::make_engine().expect("engine");
322        let _linker = build_linker(&engine).expect("linker");
323    }
324
325    #[test]
326    fn linker_with_yosh_imports_constructs() {
327        let engine = crate::precompile::make_engine().unwrap();
328        let mut linker = build_linker(&engine).unwrap();
329        register_imports(&mut linker).expect("yosh imports");
330    }
331}