use std::path::{Path, PathBuf};
use std::sync::Mutex;
use serde::{Deserialize, Serialize};
use crate::audit::{AuditContext, AuditRule, FnScan};
use crate::plugin_abi_wasm::{
SDKT_AUDIT_WASM_ABI_MAJOR, SEVERITY_CRITICAL, SEVERITY_INFO, SEVERITY_WARNING,
};
use crate::types::{AuditReport, Finding, Severity};
const MAX_FINDINGS: usize = 64;
#[derive(Debug, Serialize)]
pub struct WasmCheckInput<'a> {
pub scans: &'a [FnScan],
}
#[derive(Debug, Deserialize)]
pub struct WasmFinding {
pub rule_id: String,
pub severity: u32,
pub message: String,
pub location: Option<String>,
}
impl WasmFinding {
fn severity_enum(&self) -> Severity {
match self.severity {
s if s == SEVERITY_CRITICAL => Severity::Critical,
s if s == SEVERITY_WARNING => Severity::Warning,
s if s == SEVERITY_INFO => Severity::Info,
_ => Severity::Info,
}
}
}
#[derive(Debug)]
pub enum WasmPluginLoadError {
Io(std::io::Error),
Runtime(String),
SymbolMissing(String),
AbiMismatch {
plugin_major: u32,
host_major: u32,
},
Trap(String),
}
impl std::fmt::Display for WasmPluginLoadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
WasmPluginLoadError::Io(e) => write!(f, "io error: {}", e),
WasmPluginLoadError::Runtime(e) => write!(f, "wasm runtime error: {}", e),
WasmPluginLoadError::SymbolMissing(s) => write!(f, "missing wasm export: {}", s),
WasmPluginLoadError::AbiMismatch {
plugin_major,
host_major,
} => write!(
f,
"wasm plugin ABI mismatch (plugin v{}.x, host v{}.x)",
plugin_major, host_major
),
WasmPluginLoadError::Trap(e) => write!(f, "wasm trap/panic: {}", e),
}
}
}
impl std::error::Error for WasmPluginLoadError {}
impl From<std::io::Error> for WasmPluginLoadError {
fn from(e: std::io::Error) -> Self {
WasmPluginLoadError::Io(e)
}
}
pub struct WasmPluginRule {
id: &'static str,
severity: Severity,
description: &'static str,
plugin: Mutex<extism::Plugin>,
}
impl WasmPluginRule {
pub fn load(path: &Path, _source: &str) -> Result<Self, WasmPluginLoadError> {
let wasm_bytes = std::fs::read(path)?;
let manifest = extism::Manifest::new([extism::Wasm::data(wasm_bytes)])
.with_timeout(std::time::Duration::from_millis(15000));
let mut plugin = extism::Plugin::new(&manifest, [], true)
.map_err(|e| WasmPluginLoadError::Runtime(e.to_string()))?;
let abi_version_raw: i64 = plugin
.call::<(), i64>("sdkt_plugin_abi_version", ())
.map_err(|_| WasmPluginLoadError::SymbolMissing("sdkt_plugin_abi_version".into()))?;
let plugin_major = abi_version_raw.unsigned_abs() as u32;
if plugin_major != SDKT_AUDIT_WASM_ABI_MAJOR {
return Err(WasmPluginLoadError::AbiMismatch {
plugin_major,
host_major: SDKT_AUDIT_WASM_ABI_MAJOR,
});
}
let id: String = plugin
.call::<(), String>("sdkt_plugin_id", ())
.map_err(|_| WasmPluginLoadError::SymbolMissing("sdkt_plugin_id".into()))?;
let id_static = Box::leak(id.into_boxed_str());
let severity_raw: i64 = plugin
.call::<(), i64>("sdkt_plugin_severity", ())
.map_err(|_| WasmPluginLoadError::SymbolMissing("sdkt_plugin_severity".into()))?;
let severity = match severity_raw as u32 {
s if s == SEVERITY_CRITICAL => Severity::Critical,
s if s == SEVERITY_WARNING => Severity::Warning,
_ => Severity::Info,
};
let description: String = plugin
.call::<(), String>("sdkt_plugin_description", ())
.map_err(|_| WasmPluginLoadError::SymbolMissing("sdkt_plugin_description".into()))?;
let description_static = Box::leak(description.into_boxed_str());
Ok(WasmPluginRule {
id: id_static,
severity,
description: description_static,
plugin: Mutex::new(plugin),
})
}
}
impl AuditRule for WasmPluginRule {
fn id(&self) -> &'static str {
self.id
}
fn severity(&self) -> Severity {
self.severity
}
fn description(&self) -> &'static str {
self.description
}
fn check(&self, scans: &[FnScan], _ctx: &AuditContext, report: &mut AuditReport) {
let input = WasmCheckInput { scans };
let input_json = match serde_json::to_string(&input) {
Ok(j) => j,
Err(e) => {
report.findings.push(Finding {
rule_id: self.id.to_string(),
severity: Severity::Warning,
message: format!("internal: failed to serialise check input: {e}"),
location: None,
});
return;
}
};
let output_json = {
let mut guard = match self.plugin.lock() {
Ok(g) => g,
Err(_) => {
report.findings.push(Finding {
rule_id: self.id.to_string(),
severity: Severity::Warning,
message: "internal: wasm plugin mutex poisoned".into(),
location: None,
});
return;
}
};
match guard.call::<String, String>("sdkt_plugin_check", input_json) {
Ok(s) => s,
Err(e) => {
report.findings.push(Finding {
rule_id: self.id.to_string(),
severity: Severity::Warning,
message: format!("wasm plugin trap during check: {e}"),
location: None,
});
return;
}
}
};
let raw_findings: Vec<WasmFinding> = match serde_json::from_str(&output_json) {
Ok(v) => v,
Err(e) => {
report.findings.push(Finding {
rule_id: self.id.to_string(),
severity: Severity::Warning,
message: format!("wasm plugin returned malformed JSON: {e}"),
location: None,
});
return;
}
};
let safe_count = raw_findings.len().min(MAX_FINDINGS);
for wf in &raw_findings[..safe_count] {
report.findings.push(Finding {
rule_id: wf.rule_id.clone(),
severity: wf.severity_enum(),
message: wf.message.clone(),
location: wf.location.clone(),
});
}
}
}
pub fn load_and_register_wasm(path: &Path, source: &str) -> Result<PathBuf, WasmPluginLoadError> {
let rule = WasmPluginRule::load(path, source)?;
crate::registry::register_rule(Box::new(rule));
Ok(path.to_path_buf())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wasm_load_error_display() {
let e = WasmPluginLoadError::Runtime("test".to_string());
assert!(e.to_string().contains("wasm runtime error"));
let e = WasmPluginLoadError::SymbolMissing("sdkt_plugin_check".to_string());
assert!(e.to_string().contains("missing wasm export"));
let e = WasmPluginLoadError::AbiMismatch {
plugin_major: 2,
host_major: 1,
};
assert!(e.to_string().contains("ABI mismatch"));
let e = WasmPluginLoadError::Trap("oom".to_string());
assert!(e.to_string().contains("wasm trap"));
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
let e = WasmPluginLoadError::Io(io_err);
assert!(e.to_string().contains("io error"));
}
#[test]
fn wasm_finding_severity_mapping() {
let f = WasmFinding {
rule_id: "X".into(),
severity: SEVERITY_CRITICAL,
message: "m".into(),
location: None,
};
assert_eq!(f.severity_enum(), Severity::Critical);
let f = WasmFinding {
severity: SEVERITY_WARNING,
..f
};
assert_eq!(f.severity_enum(), Severity::Warning);
let f = WasmFinding {
severity: SEVERITY_INFO,
..f
};
assert_eq!(f.severity_enum(), Severity::Info);
let f = WasmFinding { severity: 99, ..f };
assert_eq!(f.severity_enum(), Severity::Info);
}
#[test]
fn load_missing_file_returns_io_error() {
let err = WasmPluginRule::load(Path::new("/nonexistent/plugin.wasm"), "");
assert!(matches!(err, Err(WasmPluginLoadError::Io(_))));
}
#[test]
fn load_invalid_wasm_bytes_returns_runtime_error() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), b"this is not wasm").unwrap();
let err = WasmPluginRule::load(tmp.path(), "");
assert!(matches!(err, Err(WasmPluginLoadError::Runtime(_))));
}
}