use std::borrow::Cow;
use std::path::Path;
use std::sync::Mutex;
use wasmtime::component::{Component, Linker};
use wasmtime::{Engine, Store};
use crate::scanner::{
Direction, Disposition, ScanCtx, ScanError, ScanResult, Scanner, ScannerId, Verdict,
};
wasmtime::component::bindgen!({
world: "guard",
path: "wit",
});
#[derive(Debug, thiserror::Error)]
pub enum WasmLoadError {
#[error("failed to load guard component: {0}")]
Load(String),
}
pub struct WasmScanner {
id: ScannerId,
direction: Direction,
disposition: Disposition,
instance: Mutex<GuardInstance>,
}
struct GuardInstance {
store: Store<()>,
bindings: Guard,
}
impl WasmScanner {
pub fn from_file(
path: impl AsRef<Path>,
id: ScannerId,
direction: Direction,
disposition: Disposition,
) -> Result<Self, WasmLoadError> {
let engine = Engine::default();
let component = Component::from_file(&engine, path)
.map_err(|e| WasmLoadError::Load(format!("{e:#}")))?;
Self::from_component(&engine, &component, id, direction, disposition)
}
pub fn from_bytes(
bytes: &[u8],
id: ScannerId,
direction: Direction,
disposition: Disposition,
) -> Result<Self, WasmLoadError> {
let engine = Engine::default();
let component = Component::from_binary(&engine, bytes)
.map_err(|e| WasmLoadError::Load(format!("{e:#}")))?;
Self::from_component(&engine, &component, id, direction, disposition)
}
fn from_component(
engine: &Engine,
component: &Component,
id: ScannerId,
direction: Direction,
disposition: Disposition,
) -> Result<Self, WasmLoadError> {
let linker: Linker<()> = Linker::new(engine);
let mut store = Store::new(engine, ());
let bindings = Guard::instantiate(&mut store, component, &linker)
.map_err(|e| WasmLoadError::Load(format!("{e:#}")))?;
Ok(Self {
id,
direction,
disposition,
instance: Mutex::new(GuardInstance { store, bindings }),
})
}
}
impl Scanner for WasmScanner {
fn id(&self) -> ScannerId {
self.id
}
fn direction(&self) -> Direction {
self.direction
}
fn disposition(&self) -> Disposition {
self.disposition
}
fn scan<'a>(&self, text: &'a str, _ctx: &mut ScanCtx) -> ScanResult<'a> {
let mut guard = self.instance.lock().map_err(|_| ScanError::Detector {
scanner: self.id,
reason: "guard mutex poisoned by an earlier panic".to_owned(),
})?;
let GuardInstance { store, bindings } = &mut *guard;
let verdict = bindings
.cerberust_guard_scanner()
.call_scan(&mut *store, text)
.map_err(|e| ScanError::Detector {
scanner: self.id,
reason: format!("guard trapped: {e}"),
})?;
Ok(Verdict {
text: Cow::Owned(verdict.text),
valid: verdict.valid,
risk: verdict.risk,
})
}
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
reason = "tests assert on known-good values"
)]
use super::*;
const FOO_GUARD: &[u8] = include_bytes!("../../tests/fixtures/redact-foo-guard.wasm");
const EGRESS_GUARD: &[u8] = include_bytes!("../../tests/fixtures/egress-guard.wasm");
fn foo_scanner() -> WasmScanner {
WasmScanner::from_bytes(
FOO_GUARD,
ScannerId("wasm:redact-foo"),
Direction::Input,
Disposition::Block,
)
.expect("example guard loads")
}
#[test]
fn guard_redacts_and_flags_through_scanner_surface() {
let scanner = foo_scanner();
let mut ctx = ScanCtx::new();
let verdict = scanner.scan("hello FOO world", &mut ctx).unwrap();
assert_eq!(verdict.text, "hello [REDACTED] world");
assert!(!verdict.valid);
assert!((verdict.risk - 1.0).abs() < f32::EPSILON);
}
#[test]
fn guard_passes_clean_text_unchanged() {
let scanner = foo_scanner();
let mut ctx = ScanCtx::new();
let verdict = scanner.scan("nothing to see", &mut ctx).unwrap();
assert_eq!(verdict.text, "nothing to see");
assert!(verdict.valid);
assert!(verdict.risk.abs() < f32::EPSILON);
}
#[test]
fn guard_runs_inside_a_scanner_stack() {
use crate::scanner::ScannerStack;
let mut stack = ScannerStack::new(vec![Box::new(foo_scanner())], true);
let err = stack.run_input("leak FOO please").unwrap_err();
assert_eq!(err.scanner, ScannerId("wasm:redact-foo"));
}
#[test]
fn imports_are_rejected_no_ambient_authority() {
let result = WasmScanner::from_bytes(
EGRESS_GUARD,
ScannerId("wasm:egress"),
Direction::Input,
Disposition::Block,
);
let Err(WasmLoadError::Load(msg)) = result else {
panic!("a guard importing egress must be rejected");
};
assert!(
msg.contains("egress") || msg.contains("import"),
"rejection should name the unsatisfied import, got: {msg}"
);
}
}