1use std::path::PathBuf;
22use std::sync::atomic::{AtomicBool, Ordering};
23use std::sync::Arc;
24use std::time::Duration;
25
26use extism::{
27 CurrentPlugin, Error as ExtismError, Manifest, PluginBuilder, UserData, Val, ValType, Wasm,
28};
29
30use super::detect;
31use super::manifest::Capability;
32
33const HOST_SUPPRESS: &str = "host_suppress";
37
38#[derive(Debug, Clone, Default)]
41pub struct SandboxContext {
42 pub process_pattern: Option<String>,
46 pub verdict: Arc<AtomicBool>,
50}
51
52pub const DEFAULT_MEMORY_MAX_PAGES: u32 = 64;
56
57pub const DEFAULT_TIMEOUT: Duration = Duration::from_millis(250);
61
62pub const DEFAULT_FUEL: u64 = 500_000_000;
66
67pub fn host_function_name(cap: &Capability) -> &'static str {
71 match cap {
72 Capability::DetectForegroundWindow => "host_foreground_window",
73 Capability::DetectProcesses => "host_process_running",
74 Capability::DetectFile(_) => "host_read_flag",
75 Capability::ExportFile(_) => "host_write_file",
76 Capability::ExportHttp(_) => "host_http_post",
77 }
78}
79
80fn set_bool(outputs: &mut [Val], value: bool) {
82 if let Some(out) = outputs.first_mut() {
83 *out = Val::I64(value as i64);
84 }
85}
86
87fn host_stub(
92 _plugin: &mut CurrentPlugin,
93 _inputs: &[Val],
94 outputs: &mut [Val],
95 _user_data: UserData<()>,
96) -> Result<(), ExtismError> {
97 set_bool(outputs, false);
98 Ok(())
99}
100
101fn register_probe<'a, T, F>(
105 builder: PluginBuilder<'a>,
106 name: &str,
107 data: T,
108 probe: F,
109) -> PluginBuilder<'a>
110where
111 T: Send + Sync + 'static,
112 F: Fn(&T) -> bool + Send + Sync + 'static,
113{
114 builder.with_function(
115 name,
116 [],
117 [ValType::I64],
118 UserData::new(data),
119 move |_p, _in, out, ud| {
120 let guard = ud.get()?;
121 set_bool(out, probe(&guard.lock().unwrap()));
122 Ok(())
123 },
124 )
125}
126
127fn register_capability<'a>(
132 builder: PluginBuilder<'a>,
133 cap: &Capability,
134 ctx: &SandboxContext,
135) -> PluginBuilder<'a> {
136 let name = host_function_name(cap);
137 match cap {
138 Capability::DetectProcesses => {
139 let pattern = ctx.process_pattern.clone().unwrap_or_default();
140 register_probe(builder, name, pattern, |p: &String| {
141 detect::process_running(p)
142 })
143 }
144 Capability::DetectFile(path) => {
145 register_probe(builder, name, PathBuf::from(path), |p: &PathBuf| {
146 detect::read_flag(p)
147 })
148 }
149 Capability::DetectForegroundWindow
152 | Capability::ExportFile(_)
153 | Capability::ExportHttp(_) => {
154 builder.with_function(name, [], [ValType::I64], UserData::default(), host_stub)
155 }
156 }
157}
158
159pub fn build_sandboxed_plugin(
168 module: &[u8],
169 capabilities: &[Capability],
170 ctx: &SandboxContext,
171) -> Result<extism::Plugin, String> {
172 let manifest = Manifest::new([Wasm::data(module.to_vec())])
173 .with_memory_max(DEFAULT_MEMORY_MAX_PAGES)
174 .with_timeout(DEFAULT_TIMEOUT);
175
176 let mut builder = PluginBuilder::new(manifest)
177 .with_wasi(false)
178 .with_fuel_limit(DEFAULT_FUEL);
179
180 let verdict = ctx.verdict.clone();
182 builder = builder.with_function(
183 HOST_SUPPRESS,
184 [],
185 [],
186 UserData::new(verdict),
187 |_p, _in, _out, ud| {
188 ud.get()?.lock().unwrap().store(true, Ordering::Relaxed);
189 Ok(())
190 },
191 );
192
193 let mut registered: Vec<&str> = Vec::new();
194 for cap in capabilities {
195 let name = host_function_name(cap);
196 if registered.contains(&name) {
197 continue;
198 }
199 registered.push(name);
200 builder = register_capability(builder, cap, ctx);
201 }
202
203 builder
204 .build()
205 .map_err(|e| format!("plugin failed to load in the sandbox: {e}"))
206}
207
208pub fn evaluate_detector(
214 module: &[u8],
215 capabilities: &[Capability],
216 process_pattern: Option<String>,
217) -> bool {
218 let ctx = SandboxContext {
219 process_pattern,
220 verdict: Arc::new(AtomicBool::new(false)),
221 };
222 let verdict = ctx.verdict.clone();
223 let mut plugin = match build_sandboxed_plugin(module, capabilities, &ctx) {
224 Ok(plugin) => plugin,
225 Err(_) => return false,
226 };
227 verdict.store(false, Ordering::Relaxed);
228 let _ = plugin.call::<&str, &str>("detect", "");
231 verdict.load(Ordering::Relaxed)
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 fn wasm(wat: &str) -> Vec<u8> {
239 wat::parse_str(wat).expect("valid WAT")
240 }
241
242 #[test]
243 fn host_function_name_covers_every_capability() {
244 for cap in [
245 Capability::DetectForegroundWindow,
246 Capability::DetectProcesses,
247 Capability::DetectFile("/p".to_string()),
248 Capability::ExportFile("/p".to_string()),
249 Capability::ExportHttp("127.0.0.1:8080".to_string()),
250 ] {
251 assert!(!host_function_name(&cap).is_empty());
252 }
253 }
254
255 fn trap_if_true_module(host_fn: &str) -> Vec<u8> {
260 wasm(&format!(
261 r#"(module
262 (import "extism:host/user" "{host_fn}" (func $f (result i64)))
263 (memory (export "memory") 1)
264 (func (export "run") (result i32)
265 (if (i64.ne (call $f) (i64.const 0)) (then unreachable))
266 (i32.const 0)))"#
267 ))
268 }
269
270 #[test]
271 fn loads_a_clean_module_with_no_imports() {
272 let module = wasm(
273 r#"(module
274 (memory (export "memory") 1)
275 (func (export "run") (result i32) i32.const 0))"#,
276 );
277 assert!(build_sandboxed_plugin(&module, &[], &SandboxContext::default()).is_ok());
278 }
279
280 #[test]
292 fn rejects_a_module_importing_the_wasi_filesystem() {
293 let module = wasm(
295 r#"(module
296 (import "wasi_snapshot_preview1" "path_open"
297 (func $open (param i32 i32 i32 i32 i32 i64 i64 i32 i32) (result i32)))
298 (memory (export "memory") 1)
299 (func (export "run") (result i32) i32.const 0))"#,
300 );
301 let err = build_sandboxed_plugin(&module, &[], &SandboxContext::default())
302 .expect_err("WASI must not be linked into the plugin sandbox");
303 assert!(
304 err.contains("failed to load"),
305 "a wasi_snapshot_preview1 import must fail the load, got: {err}"
306 );
307 }
308
309 #[test]
310 fn rejects_a_module_importing_an_ungranted_host_function() {
311 let module = trap_if_true_module("host_process_running");
313 let err = build_sandboxed_plugin(&module, &[], &SandboxContext::default()).unwrap_err();
314 assert!(
315 err.contains("failed to load"),
316 "ungranted import should fail the load, got: {err}"
317 );
318 }
319
320 #[test]
321 fn host_process_running_reports_a_live_process_to_the_module() {
322 let module = trap_if_true_module("host_process_running");
327 let mut plugin = build_sandboxed_plugin(
328 &module,
329 &[Capability::DetectProcesses],
330 &SandboxContext {
331 process_pattern: Some("entracte".to_string()),
332 ..Default::default()
333 },
334 )
335 .expect("granted detect:processes builds");
336 assert!(
337 plugin.call::<&str, &str>("run", "").is_err(),
338 "a matching process should be reported true (module traps)"
339 );
340
341 let module = trap_if_true_module("host_process_running");
343 let mut plugin = build_sandboxed_plugin(
344 &module,
345 &[Capability::DetectProcesses],
346 &SandboxContext {
347 process_pattern: Some("entracte-no-such-process-zzz".to_string()),
348 ..Default::default()
349 },
350 )
351 .unwrap();
352 assert!(plugin.call::<&str, &str>("run", "").is_ok());
353 }
354
355 #[test]
356 fn host_read_flag_reports_the_granted_files_truthiness() {
357 let dir = crate::test_support::temp_dir();
358 let flag = dir.path().join("focus.flag");
359 std::fs::write(&flag, "true").unwrap();
360 let cap = Capability::DetectFile(flag.display().to_string());
361
362 let module = trap_if_true_module("host_read_flag");
364 let mut plugin = build_sandboxed_plugin(
365 &module,
366 std::slice::from_ref(&cap),
367 &SandboxContext::default(),
368 )
369 .expect("granted detect:file builds");
370 assert!(plugin.call::<&str, &str>("run", "").is_err());
371
372 std::fs::write(&flag, "0").unwrap();
374 let module = trap_if_true_module("host_read_flag");
375 let mut plugin = build_sandboxed_plugin(
376 &module,
377 std::slice::from_ref(&cap),
378 &SandboxContext::default(),
379 )
380 .unwrap();
381 assert!(plugin.call::<&str, &str>("run", "").is_ok());
382 }
383
384 #[test]
385 fn a_stubbed_capability_registers_an_inert_host_function() {
386 let module = trap_if_true_module("host_foreground_window");
390 let mut plugin = build_sandboxed_plugin(
391 &module,
392 &[Capability::DetectForegroundWindow],
393 &SandboxContext::default(),
394 )
395 .expect("granted detect:foreground-window builds");
396 assert!(
397 plugin.call::<&str, &str>("run", "").is_ok(),
398 "the stub should return false (no suppression)"
399 );
400 }
401
402 #[test]
403 fn duplicate_capabilities_register_their_host_function_once() {
404 let module = trap_if_true_module("host_read_flag");
407 let grants = vec![
408 Capability::DetectFile("/tmp/a.flag".to_string()),
409 Capability::DetectFile("/tmp/b.flag".to_string()),
410 ];
411 assert!(build_sandboxed_plugin(&module, &grants, &SandboxContext::default()).is_ok());
412 }
413
414 fn suppress_if_process_module() -> Vec<u8> {
417 wasm(
418 r#"(module
419 (import "extism:host/user" "host_process_running" (func $proc (result i64)))
420 (import "extism:host/user" "host_suppress" (func $suppress))
421 (memory (export "memory") 1)
422 (func (export "detect") (result i32)
423 (if (i64.ne (call $proc) (i64.const 0)) (then (call $suppress)))
424 (i32.const 0)))"#,
425 )
426 }
427
428 #[test]
429 fn evaluate_detector_reports_the_modules_verdict() {
430 let module = suppress_if_process_module();
431 assert!(evaluate_detector(
433 &module,
434 &[Capability::DetectProcesses],
435 Some("entracte".to_string())
436 ));
437 assert!(!evaluate_detector(
439 &module,
440 &[Capability::DetectProcesses],
441 Some("entracte-no-such-process-zzz".to_string())
442 ));
443 }
444
445 #[test]
446 fn evaluate_detector_is_false_when_the_module_does_not_vote() {
447 let module = wasm(
449 r#"(module
450 (memory (export "memory") 1)
451 (func (export "detect") (result i32) (i32.const 0)))"#,
452 );
453 assert!(!evaluate_detector(&module, &[], None));
454 }
455
456 #[test]
457 fn evaluate_detector_treats_a_broken_module_as_no_suppression() {
458 let no_export = wasm(r#"(module (memory (export "memory") 1))"#);
460 assert!(!evaluate_detector(&no_export, &[], None));
461
462 let ungranted = trap_if_true_module("host_foreground_window");
464 assert!(!evaluate_detector(&ungranted, &[], None));
465 }
466
467 #[test]
468 fn a_runaway_module_is_trapped_not_hung() {
469 let module = wasm(
472 r#"(module
473 (memory (export "memory") 1)
474 (func (export "run") (result i32)
475 (loop $l (br $l))
476 (i32.const 0)))"#,
477 );
478 let mut plugin =
479 build_sandboxed_plugin(&module, &[], &SandboxContext::default()).expect("module loads");
480 let started = std::time::Instant::now();
481 let result = plugin.call::<&str, &str>("run", "");
482 let elapsed = started.elapsed();
483 assert!(result.is_err(), "runaway call must error, not return");
484 assert!(
485 elapsed < Duration::from_secs(5),
486 "metering must abort the runaway promptly"
487 );
488 }
489}