Skip to main content

entracte_lib/plugins/
eval.rs

1//! Aggregating installed detectors into a single suppress / don't verdict
2//! (#156, slice 5b). The off-tick detector-eval task snapshots the installed
3//! detectors, loads each module, and asks [`any_detector_suppresses`]; the
4//! result feeds the 1Hz loop's suppression chain via `Scheduler::plugin_suppress`.
5//!
6//! The aggregation is pure (the module loader is injected), so it's
7//! unit-testable with wat modules and a fake loader — no disk, no scheduler.
8
9use super::manifest::Capability;
10use super::runtime::evaluate_detector;
11
12/// The minimum a detector needs to be evaluated: its id (to load the module),
13/// its granted capabilities (to rebuild the sandbox), and its process pattern.
14#[derive(Debug, Clone)]
15pub struct DetectorSnapshot {
16    pub id: String,
17    pub capabilities: Vec<Capability>,
18    pub process_pattern: Option<String>,
19}
20
21/// Whether **any** snapshotted detector votes to suppress the next break.
22/// `load` fetches a detector's module bytes by id (`None` → skip it, e.g. a
23/// missing/unreadable module). Short-circuits on the first suppressing
24/// detector. A detector whose module fails to build or run contributes no
25/// suppression (see [`evaluate_detector`]).
26pub fn any_detector_suppresses(
27    detectors: &[DetectorSnapshot],
28    load: impl Fn(&str) -> Option<Vec<u8>>,
29) -> bool {
30    detectors.iter().any(|d| match load(&d.id) {
31        Some(module) => evaluate_detector(&module, &d.capabilities, d.process_pattern.clone()),
32        None => false,
33    })
34}
35
36#[cfg(test)]
37mod tests {
38    use super::*;
39
40    /// A detector module that votes suppress (via host_suppress) iff the
41    /// granted process probe matches.
42    fn suppress_if_process_module() -> Vec<u8> {
43        wat::parse_str(
44            r#"(module
45                 (import "extism:host/user" "host_process_running" (func $proc (result i64)))
46                 (import "extism:host/user" "host_suppress" (func $suppress))
47                 (memory (export "memory") 1)
48                 (func (export "detect") (result i32)
49                   (if (i64.ne (call $proc) (i64.const 0)) (then (call $suppress)))
50                   (i32.const 0)))"#,
51        )
52        .unwrap()
53    }
54
55    fn snapshot(id: &str, pattern: &str) -> DetectorSnapshot {
56        DetectorSnapshot {
57            id: id.to_string(),
58            capabilities: vec![Capability::DetectProcesses],
59            process_pattern: Some(pattern.to_string()),
60        }
61    }
62
63    #[test]
64    fn suppresses_when_any_detector_votes() {
65        let module = suppress_if_process_module();
66        let load = |_id: &str| Some(module.clone());
67        // "entracte" matches the test binary → that detector votes suppress.
68        let detectors = vec![
69            snapshot("com.x.idle", "entracte-no-such-zzz"),
70            snapshot("com.x.focus", "entracte"),
71        ];
72        assert!(any_detector_suppresses(&detectors, load));
73    }
74
75    #[test]
76    fn no_suppression_when_none_vote() {
77        let module = suppress_if_process_module();
78        let detectors = vec![snapshot("com.x.idle", "entracte-no-such-zzz")];
79        assert!(!any_detector_suppresses(&detectors, |_| Some(
80            module.clone()
81        )));
82    }
83
84    #[test]
85    fn a_missing_module_is_skipped() {
86        let detectors = vec![snapshot("com.x.gone", "entracte")];
87        // Loader returns None (module gone) → no suppression, no panic.
88        assert!(!any_detector_suppresses(&detectors, |_| None));
89    }
90
91    #[test]
92    fn empty_set_does_not_suppress() {
93        assert!(!any_detector_suppresses(&[], |_| Some(vec![1, 2, 3])));
94    }
95}