Skip to main content

rns_hooks/
manager.rs

1use crate::arena;
2use crate::engine_access::{EngineAccess, NullEngine};
3use crate::error::HookError;
4use crate::hooks::HookContext;
5use crate::host_fns;
6use crate::program::LoadedProgram;
7use crate::result::{ExecuteResult, HookResult, Verdict};
8use crate::runtime::{StoreData, WasmRuntime};
9use wasmtime::{Linker, Store};
10
11/// ABI version the host expects from compiled hook modules.
12const HOST_ABI_VERSION: i32 = rns_hooks_abi::ABI_VERSION;
13
14/// Central manager for WASM hook execution.
15///
16/// Owns the wasmtime runtime and pre-configured linker. Programs are stored
17/// in `HookSlot`s (one per hook point); the manager provides execution.
18pub struct HookManager {
19    runtime: WasmRuntime,
20    linker: Linker<StoreData>,
21}
22
23impl HookManager {
24    pub fn new() -> Result<Self, HookError> {
25        let runtime = WasmRuntime::new().map_err(|e| HookError::CompileError(e.to_string()))?;
26        let mut linker = Linker::new(runtime.engine());
27        host_fns::register_host_functions(&mut linker)
28            .map_err(|e| HookError::CompileError(e.to_string()))?;
29        Ok(HookManager { runtime, linker })
30    }
31
32    /// Compile WASM bytes into a LoadedProgram.
33    ///
34    /// Validates that the module exports `__rns_abi_version` returning the
35    /// expected ABI version before accepting it.
36    pub fn compile(
37        &self,
38        name: String,
39        bytes: &[u8],
40        priority: i32,
41    ) -> Result<LoadedProgram, HookError> {
42        let module = self
43            .runtime
44            .compile(bytes)
45            .map_err(|e| HookError::CompileError(e.to_string()))?;
46        self.validate_abi_version(&name, &module)?;
47        Ok(LoadedProgram::new(name, module, priority))
48    }
49
50    /// Check that the module exports `__rns_abi_version() -> i32` and that
51    /// the returned value matches [`HOST_ABI_VERSION`].
52    fn validate_abi_version(
53        &self,
54        name: &str,
55        module: &wasmtime::Module,
56    ) -> Result<(), HookError> {
57        // Check if the export exists in the module's type information.
58        let has_export = module
59            .exports()
60            .any(|e| e.name() == "__rns_abi_version");
61        if !has_export {
62            return Err(HookError::AbiVersionMismatch {
63                hook_name: name.to_string(),
64                expected: HOST_ABI_VERSION,
65                found: None,
66            });
67        }
68
69        // Instantiate the module to call the function and read the version.
70        static NULL_ENGINE: NullEngine = NullEngine;
71        let mut store = Store::new(self.runtime.engine(), StoreData {
72            engine_access: &NULL_ENGINE as *const dyn EngineAccess,
73            now: 0.0,
74            injected_actions: Vec::new(),
75            log_messages: Vec::new(),
76        });
77        store
78            .set_fuel(self.runtime.fuel())
79            .map_err(|e| HookError::CompileError(e.to_string()))?;
80
81        let instance = self
82            .linker
83            .instantiate(&mut store, module)
84            .map_err(|e| HookError::InstantiationError(e.to_string()))?;
85
86        let func = instance
87            .get_typed_func::<(), i32>(&mut store, "__rns_abi_version")
88            .map_err(|e| HookError::CompileError(format!(
89                "__rns_abi_version has wrong signature: {}", e
90            )))?;
91
92        let version = func
93            .call(&mut store, ())
94            .map_err(|e| HookError::Trap(format!(
95                "__rns_abi_version trapped: {}", e
96            )))?;
97
98        if version != HOST_ABI_VERSION {
99            return Err(HookError::AbiVersionMismatch {
100                hook_name: name.to_string(),
101                expected: HOST_ABI_VERSION,
102                found: Some(version),
103            });
104        }
105
106        Ok(())
107    }
108
109    /// Compile a WASM file from disk.
110    pub fn load_file(
111        &self,
112        name: String,
113        path: &std::path::Path,
114        priority: i32,
115    ) -> Result<LoadedProgram, HookError> {
116        let bytes = std::fs::read(path)?;
117        self.compile(name, &bytes, priority)
118    }
119
120    /// Execute a single program against a hook context. Returns an `ExecuteResult`
121    /// containing the hook result, any injected actions, and modified data (all
122    /// extracted from WASM memory before the store is dropped). Returns `None`
123    /// on trap/fuel exhaustion (fail-open).
124    ///
125    /// If `data_override` is provided (from a previous Modify verdict in a chain),
126    /// it replaces the packet data region in the arena after writing the context.
127    ///
128    /// The store and instance are cached in the program for cross-call state
129    /// persistence (WASM linear memory survives across invocations). On each call
130    /// we reset fuel and per-call StoreData fields but keep the WASM globals and
131    /// memory intact.
132    pub fn execute_program(
133        &self,
134        program: &mut LoadedProgram,
135        ctx: &HookContext,
136        engine_access: &dyn EngineAccess,
137        now: f64,
138        data_override: Option<&[u8]>,
139    ) -> Option<ExecuteResult> {
140        if !program.enabled {
141            return None;
142        }
143
144        // Safety: transmute erases the lifetime on the fat pointer. The pointer
145        // is only dereferenced during this function call, while the borrow is valid.
146        let engine_access_ptr: *const dyn EngineAccess = unsafe {
147            std::mem::transmute(engine_access as *const dyn EngineAccess)
148        };
149
150        // Take the cached store+instance out of program (or create fresh).
151        // We take ownership to avoid borrow-checker conflicts with program.record_*().
152        let (mut store, instance) = if let Some(cached) = program.cached.take() {
153            let (mut s, i) = cached;
154            // Reset per-call state: fuel, engine_access, injected_actions, log_messages
155            s.data_mut().reset_per_call(engine_access_ptr, now);
156            if let Err(e) = s.set_fuel(self.runtime.fuel()) {
157                log::warn!("failed to set fuel for hook '{}': {}", program.name, e);
158                program.cached = Some((s, i));
159                return None;
160            }
161            (s, i)
162        } else {
163            let store_data = StoreData {
164                engine_access: engine_access_ptr,
165                now,
166                injected_actions: Vec::new(),
167                log_messages: Vec::new(),
168            };
169
170            let mut store = Store::new(self.runtime.engine(), store_data);
171            if let Err(e) = store.set_fuel(self.runtime.fuel()) {
172                log::warn!("failed to set fuel for hook '{}': {}", program.name, e);
173                return None;
174            }
175
176            let instance = match self.linker.instantiate(&mut store, &program.module) {
177                Ok(inst) => inst,
178                Err(e) => {
179                    log::warn!("failed to instantiate hook '{}': {}", program.name, e);
180                    program.record_trap();
181                    return None;
182                }
183            };
184
185            (store, instance)
186        };
187
188        // Write context into guest memory
189        let memory = match instance.get_memory(&mut store, "memory") {
190            Some(mem) => mem,
191            None => {
192                log::warn!("hook '{}' has no exported memory", program.name);
193                program.record_trap();
194                program.cached = Some((store, instance));
195                return None;
196            }
197        };
198
199        if let Err(e) = arena::write_context(&memory, &mut store, ctx) {
200            log::warn!("failed to write context for hook '{}': {}", program.name, e);
201            program.record_trap();
202            program.cached = Some((store, instance));
203            return None;
204        }
205
206        // If a previous hook in the chain returned Modify, override the packet data
207        if let Some(override_data) = data_override {
208            if let Err(e) = arena::write_data_override(&memory, &mut store, override_data) {
209                log::warn!(
210                    "failed to write data override for hook '{}': {}",
211                    program.name,
212                    e
213                );
214                // Non-fatal: continue with original data
215            }
216        }
217
218        // Call the exported hook function
219        let func = match instance.get_typed_func::<i32, i32>(&mut store, &program.export_name) {
220            Ok(f) => f,
221            Err(e) => {
222                log::warn!(
223                    "hook '{}' missing export '{}': {}",
224                    program.name,
225                    program.export_name,
226                    e
227                );
228                program.record_trap();
229                program.cached = Some((store, instance));
230                return None;
231            }
232        };
233
234        let result_offset = match func.call(&mut store, arena::ARENA_BASE as i32) {
235            Ok(offset) => offset,
236            Err(e) => {
237                // Fail-open: trap or fuel exhaustion → continue
238                let auto_disabled = program.record_trap();
239                if auto_disabled {
240                    log::error!(
241                        "hook '{}' auto-disabled after {} consecutive traps",
242                        program.name,
243                        program.consecutive_traps
244                    );
245                } else {
246                    log::warn!("hook '{}' trapped: {}", program.name, e);
247                }
248                program.cached = Some((store, instance));
249                return None;
250            }
251        };
252
253        // Read result from guest memory
254        let ret = match arena::read_result(&memory, &store, result_offset as usize) {
255            Ok(result) => {
256                program.record_success();
257
258                // Extract modified data from WASM memory
259                let modified_data = if Verdict::from_u32(result.verdict) == Some(Verdict::Modify) {
260                    arena::read_modified_data(&memory, &store, &result)
261                } else {
262                    None
263                };
264
265                // Extract injected actions from the store
266                let injected_actions = std::mem::take(&mut store.data_mut().injected_actions);
267
268                Some(ExecuteResult {
269                    hook_result: Some(result),
270                    injected_actions,
271                    modified_data,
272                })
273            }
274            Err(e) => {
275                log::warn!("hook '{}' returned invalid result: {}", program.name, e);
276                program.record_trap();
277                None
278            }
279        };
280
281        // Put the store+instance back for next call
282        program.cached = Some((store, instance));
283        ret
284    }
285
286    /// Run a chain of programs. Stops on Drop or Halt, continues on Continue or Modify.
287    /// Returns an `ExecuteResult` accumulating all injected actions across the chain
288    /// and the last meaningful hook result (Drop/Halt/Modify), or None if all continued.
289    ///
290    /// When a hook returns Modify with modified data, subsequent hooks in the chain
291    /// receive the modified data (only applicable to Packet contexts).
292    pub fn run_chain(
293        &self,
294        programs: &mut [LoadedProgram],
295        ctx: &HookContext,
296        engine_access: &dyn EngineAccess,
297        now: f64,
298    ) -> Option<ExecuteResult> {
299        let mut accumulated_actions = Vec::new();
300        let mut last_result: Option<HookResult> = None;
301        let mut last_modified_data: Option<Vec<u8>> = None;
302        let is_packet_ctx = matches!(ctx, HookContext::Packet { .. });
303
304        for program in programs.iter_mut() {
305            if !program.enabled {
306                continue;
307            }
308            let override_ref = if is_packet_ctx {
309                last_modified_data.as_deref()
310            } else {
311                None
312            };
313            if let Some(exec_result) =
314                self.execute_program(program, ctx, engine_access, now, override_ref)
315            {
316                accumulated_actions.extend(exec_result.injected_actions);
317
318                if let Some(ref result) = exec_result.hook_result {
319                    let verdict = Verdict::from_u32(result.verdict);
320                    match verdict {
321                        Some(Verdict::Drop) | Some(Verdict::Halt) => {
322                            return Some(ExecuteResult {
323                                hook_result: exec_result.hook_result,
324                                injected_actions: accumulated_actions,
325                                modified_data: exec_result.modified_data.or(last_modified_data),
326                            });
327                        }
328                        Some(Verdict::Modify) => {
329                            last_result = exec_result.hook_result;
330                            if is_packet_ctx {
331                                if let Some(data) = exec_result.modified_data {
332                                    last_modified_data = Some(data);
333                                }
334                            }
335                        }
336                        _ => {} // Continue → keep going
337                    }
338                }
339            }
340        }
341
342        if last_result.is_some() || !accumulated_actions.is_empty() {
343            Some(ExecuteResult {
344                hook_result: last_result,
345                injected_actions: accumulated_actions,
346                modified_data: last_modified_data,
347            })
348        } else {
349            None
350        }
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use crate::engine_access::NullEngine;
358
359    fn make_manager() -> HookManager {
360        HookManager::new().expect("failed to create HookManager")
361    }
362
363    /// WAT module that returns Continue (verdict=0).
364    const WAT_CONTINUE: &str = r#"
365        (module
366            (memory (export "memory") 1)
367            (func (export "__rns_abi_version") (result i32) (i32.const 1))
368            (func (export "on_hook") (param i32) (result i32)
369                ;; Write HookResult at offset 0x2000
370                ;; verdict = 0 (Continue)
371                (i32.store (i32.const 0x2000) (i32.const 0))
372                ;; modified_data_offset = 0
373                (i32.store (i32.add (i32.const 0x2000) (i32.const 4)) (i32.const 0))
374                ;; modified_data_len = 0
375                (i32.store (i32.add (i32.const 0x2000) (i32.const 8)) (i32.const 0))
376                ;; inject_actions_offset = 0
377                (i32.store (i32.add (i32.const 0x2000) (i32.const 12)) (i32.const 0))
378                ;; inject_actions_count = 0
379                (i32.store (i32.add (i32.const 0x2000) (i32.const 16)) (i32.const 0))
380                ;; log_offset = 0
381                (i32.store (i32.add (i32.const 0x2000) (i32.const 20)) (i32.const 0))
382                ;; log_len = 0
383                (i32.store (i32.add (i32.const 0x2000) (i32.const 24)) (i32.const 0))
384                (i32.const 0x2000)
385            )
386        )
387    "#;
388
389    /// WAT module that returns Drop (verdict=1).
390    const WAT_DROP: &str = r#"
391        (module
392            (memory (export "memory") 1)
393            (func (export "__rns_abi_version") (result i32) (i32.const 1))
394            (func (export "on_hook") (param i32) (result i32)
395                (i32.store (i32.const 0x2000) (i32.const 1))
396                (i32.store (i32.add (i32.const 0x2000) (i32.const 4)) (i32.const 0))
397                (i32.store (i32.add (i32.const 0x2000) (i32.const 8)) (i32.const 0))
398                (i32.store (i32.add (i32.const 0x2000) (i32.const 12)) (i32.const 0))
399                (i32.store (i32.add (i32.const 0x2000) (i32.const 16)) (i32.const 0))
400                (i32.store (i32.add (i32.const 0x2000) (i32.const 20)) (i32.const 0))
401                (i32.store (i32.add (i32.const 0x2000) (i32.const 24)) (i32.const 0))
402                (i32.const 0x2000)
403            )
404        )
405    "#;
406
407    /// WAT module that traps immediately.
408    const WAT_TRAP: &str = r#"
409        (module
410            (memory (export "memory") 1)
411            (func (export "__rns_abi_version") (result i32) (i32.const 1))
412            (func (export "on_hook") (param i32) (result i32)
413                unreachable
414            )
415        )
416    "#;
417
418    /// WAT module with infinite loop (will exhaust fuel).
419    const WAT_INFINITE: &str = r#"
420        (module
421            (memory (export "memory") 1)
422            (func (export "__rns_abi_version") (result i32) (i32.const 1))
423            (func (export "on_hook") (param i32) (result i32)
424                (loop $inf (br $inf))
425                (i32.const 0)
426            )
427        )
428    "#;
429
430    /// WAT module that calls host_has_path and drops if path exists.
431    const WAT_HOST_HAS_PATH: &str = r#"
432        (module
433            (import "env" "host_has_path" (func $has_path (param i32) (result i32)))
434            (memory (export "memory") 1)
435            (func (export "__rns_abi_version") (result i32) (i32.const 1))
436            (func (export "on_hook") (param $ctx_ptr i32) (result i32)
437                ;; Check if path exists for a 16-byte dest at offset 0x3000
438                ;; (we'll write the dest hash there in the test)
439                (if (call $has_path (i32.const 0x3000))
440                    (then
441                        ;; Drop
442                        (i32.store (i32.const 0x2000) (i32.const 1))
443                    )
444                    (else
445                        ;; Continue
446                        (i32.store (i32.const 0x2000) (i32.const 0))
447                    )
448                )
449                (i32.store (i32.add (i32.const 0x2000) (i32.const 4)) (i32.const 0))
450                (i32.store (i32.add (i32.const 0x2000) (i32.const 8)) (i32.const 0))
451                (i32.store (i32.add (i32.const 0x2000) (i32.const 12)) (i32.const 0))
452                (i32.store (i32.add (i32.const 0x2000) (i32.const 16)) (i32.const 0))
453                (i32.store (i32.add (i32.const 0x2000) (i32.const 20)) (i32.const 0))
454                (i32.store (i32.add (i32.const 0x2000) (i32.const 24)) (i32.const 0))
455                (i32.const 0x2000)
456            )
457        )
458    "#;
459
460    #[test]
461    fn pass_through() {
462        let mgr = make_manager();
463        let mut prog = mgr
464            .compile("test".into(), WAT_CONTINUE.as_bytes(), 0)
465            .unwrap();
466        let ctx = HookContext::Tick;
467        let result = mgr.execute_program(&mut prog, &ctx, &NullEngine, 0.0, None);
468        // Continue → Some with verdict=0
469        let exec = result.unwrap();
470        let r = exec.hook_result.unwrap();
471        assert_eq!(r.verdict, Verdict::Continue as u32);
472    }
473
474    #[test]
475    fn drop_hook() {
476        let mgr = make_manager();
477        let mut prog = mgr.compile("dropper".into(), WAT_DROP.as_bytes(), 0).unwrap();
478        let ctx = HookContext::Tick;
479        let result = mgr.execute_program(&mut prog, &ctx, &NullEngine, 0.0, None);
480        let exec = result.unwrap();
481        let r = exec.hook_result.unwrap();
482        assert!(r.is_drop());
483    }
484
485    #[test]
486    fn trap_failopen() {
487        let mgr = make_manager();
488        let mut prog = mgr.compile("trap".into(), WAT_TRAP.as_bytes(), 0).unwrap();
489        let ctx = HookContext::Tick;
490        let result = mgr.execute_program(&mut prog, &ctx, &NullEngine, 0.0, None);
491        assert!(result.is_none());
492        assert_eq!(prog.consecutive_traps, 1);
493        assert!(prog.enabled);
494    }
495
496    #[test]
497    fn auto_disable() {
498        let mgr = make_manager();
499        let mut prog = mgr.compile("bad".into(), WAT_TRAP.as_bytes(), 0).unwrap();
500        let ctx = HookContext::Tick;
501        for _ in 0..10 {
502            let _ = mgr.execute_program(&mut prog, &ctx, &NullEngine, 0.0, None);
503        }
504        assert!(!prog.enabled);
505        assert_eq!(prog.consecutive_traps, 10);
506    }
507
508    #[test]
509    fn fuel_exhaustion() {
510        let mgr = make_manager();
511        let mut prog = mgr
512            .compile("loop".into(), WAT_INFINITE.as_bytes(), 0)
513            .unwrap();
514        let ctx = HookContext::Tick;
515        let result = mgr.execute_program(&mut prog, &ctx, &NullEngine, 0.0, None);
516        // Should fail-open (fuel exhausted = trap)
517        assert!(result.is_none());
518        assert_eq!(prog.consecutive_traps, 1);
519    }
520
521    #[test]
522    fn chain_ordering() {
523        let mgr = make_manager();
524        let high = mgr
525            .compile("high".into(), WAT_DROP.as_bytes(), 100)
526            .unwrap();
527        let low = mgr
528            .compile("low".into(), WAT_CONTINUE.as_bytes(), 0)
529            .unwrap();
530        // Programs sorted by priority desc: high first
531        let mut programs = vec![high, low];
532        // Sort descending by priority (as attach would do)
533        programs.sort_by(|a, b| b.priority.cmp(&a.priority));
534
535        let ctx = HookContext::Tick;
536        let result = mgr.run_chain(&mut programs, &ctx, &NullEngine, 0.0);
537        // High priority drops → chain stops
538        let exec = result.unwrap();
539        let r = exec.hook_result.unwrap();
540        assert!(r.is_drop());
541    }
542
543    #[test]
544    fn attach_detach() {
545        use crate::hooks::HookSlot;
546
547        let mgr = make_manager();
548        let mut slot = HookSlot {
549            programs: Vec::new(),
550            runner: crate::hooks::hook_noop,
551        };
552
553        let p1 = mgr
554            .compile("alpha".into(), WAT_CONTINUE.as_bytes(), 10)
555            .unwrap();
556        let p2 = mgr
557            .compile("beta".into(), WAT_DROP.as_bytes(), 20)
558            .unwrap();
559
560        slot.attach(p1);
561        assert_eq!(slot.programs.len(), 1);
562        assert!(slot.runner as *const () as usize != crate::hooks::hook_noop as *const () as usize);
563
564        slot.attach(p2);
565        assert_eq!(slot.programs.len(), 2);
566        // Sorted descending: beta(20) before alpha(10)
567        assert_eq!(slot.programs[0].name, "beta");
568        assert_eq!(slot.programs[1].name, "alpha");
569
570        let removed = slot.detach("beta");
571        assert!(removed.is_some());
572        assert_eq!(slot.programs.len(), 1);
573        assert_eq!(slot.programs[0].name, "alpha");
574
575        let removed2 = slot.detach("alpha");
576        assert!(removed2.is_some());
577        assert!(slot.programs.is_empty());
578        assert_eq!(slot.runner as *const () as usize, crate::hooks::hook_noop as *const () as usize);
579    }
580
581    #[test]
582    fn host_has_path() {
583        use crate::engine_access::EngineAccess;
584
585        struct MockEngine;
586        impl EngineAccess for MockEngine {
587            fn has_path(&self, _dest: &[u8; 16]) -> bool {
588                true
589            }
590            fn hops_to(&self, _: &[u8; 16]) -> Option<u8> {
591                None
592            }
593            fn next_hop(&self, _: &[u8; 16]) -> Option<[u8; 16]> {
594                None
595            }
596            fn is_blackholed(&self, _: &[u8; 16]) -> bool {
597                false
598            }
599            fn interface_name(&self, _: u64) -> Option<String> {
600                None
601            }
602            fn interface_mode(&self, _: u64) -> Option<u8> {
603                None
604            }
605            fn identity_hash(&self) -> Option<[u8; 16]> {
606                None
607            }
608            fn announce_rate(&self, _: u64) -> Option<i32> {
609                None
610            }
611            fn link_state(&self, _: &[u8; 16]) -> Option<u8> {
612                None
613            }
614        }
615
616        let mgr = make_manager();
617        let mut prog = mgr
618            .compile("pathcheck".into(), WAT_HOST_HAS_PATH.as_bytes(), 0)
619            .unwrap();
620        let ctx = HookContext::Tick;
621        let result = mgr.execute_program(&mut prog, &ctx, &MockEngine, 0.0, None);
622        // MockEngine.has_path returns true → WASM drops
623        let exec = result.unwrap();
624        let r = exec.hook_result.unwrap();
625        assert!(r.is_drop());
626    }
627
628    #[test]
629    fn host_has_path_null_engine() {
630        // NullEngine.has_path returns false → WASM continues
631        let mgr = make_manager();
632        let mut prog = mgr
633            .compile("pathcheck".into(), WAT_HOST_HAS_PATH.as_bytes(), 0)
634            .unwrap();
635        let ctx = HookContext::Tick;
636        let result = mgr.execute_program(&mut prog, &ctx, &NullEngine, 0.0, None);
637        let exec = result.unwrap();
638        let r = exec.hook_result.unwrap();
639        assert_eq!(r.verdict, Verdict::Continue as u32);
640    }
641
642    // --- New Phase 2 tests ---
643
644    /// Configurable mock engine for testing host functions.
645    struct MockEngineCustom {
646        announce_rate_val: Option<i32>,
647        link_state_val: Option<u8>,
648    }
649
650    impl EngineAccess for MockEngineCustom {
651        fn has_path(&self, _: &[u8; 16]) -> bool { false }
652        fn hops_to(&self, _: &[u8; 16]) -> Option<u8> { None }
653        fn next_hop(&self, _: &[u8; 16]) -> Option<[u8; 16]> { None }
654        fn is_blackholed(&self, _: &[u8; 16]) -> bool { false }
655        fn interface_name(&self, _: u64) -> Option<String> { None }
656        fn interface_mode(&self, _: u64) -> Option<u8> { None }
657        fn identity_hash(&self) -> Option<[u8; 16]> { None }
658        fn announce_rate(&self, _: u64) -> Option<i32> { self.announce_rate_val }
659        fn link_state(&self, _: &[u8; 16]) -> Option<u8> { self.link_state_val }
660    }
661
662    /// WAT: calls host_get_announce_rate(42), if result >= 0 → Drop, else Continue.
663    const WAT_ANNOUNCE_RATE: &str = r#"
664        (module
665            (import "env" "host_get_announce_rate" (func $get_rate (param i64) (result i32)))
666            (memory (export "memory") 1)
667            (func (export "__rns_abi_version") (result i32) (i32.const 1))
668            (func (export "on_hook") (param $ctx_ptr i32) (result i32)
669                (if (i32.ge_s (call $get_rate (i64.const 42)) (i32.const 0))
670                    (then
671                        (i32.store (i32.const 0x2000) (i32.const 1)) ;; Drop
672                    )
673                    (else
674                        (i32.store (i32.const 0x2000) (i32.const 0)) ;; Continue
675                    )
676                )
677                (i32.store (i32.add (i32.const 0x2000) (i32.const 4)) (i32.const 0))
678                (i32.store (i32.add (i32.const 0x2000) (i32.const 8)) (i32.const 0))
679                (i32.store (i32.add (i32.const 0x2000) (i32.const 12)) (i32.const 0))
680                (i32.store (i32.add (i32.const 0x2000) (i32.const 16)) (i32.const 0))
681                (i32.store (i32.add (i32.const 0x2000) (i32.const 20)) (i32.const 0))
682                (i32.store (i32.add (i32.const 0x2000) (i32.const 24)) (i32.const 0))
683                (i32.const 0x2000)
684            )
685        )
686    "#;
687
688    #[test]
689    fn host_get_announce_rate_found() {
690        // announce_rate returns Some(1500) (1.5 Hz * 1000) → Drop
691        let engine = MockEngineCustom { announce_rate_val: Some(1500), link_state_val: None };
692        let mgr = make_manager();
693        let mut prog = mgr.compile("rate".into(), WAT_ANNOUNCE_RATE.as_bytes(), 0).unwrap();
694        let ctx = HookContext::Tick;
695        let exec = mgr.execute_program(&mut prog, &ctx, &engine, 0.0, None).unwrap();
696        assert!(exec.hook_result.unwrap().is_drop());
697    }
698
699    #[test]
700    fn host_get_announce_rate_not_found() {
701        // announce_rate returns None → -1 → Continue
702        let engine = MockEngineCustom { announce_rate_val: None, link_state_val: None };
703        let mgr = make_manager();
704        let mut prog = mgr.compile("rate".into(), WAT_ANNOUNCE_RATE.as_bytes(), 0).unwrap();
705        let ctx = HookContext::Tick;
706        let exec = mgr.execute_program(&mut prog, &ctx, &engine, 0.0, None).unwrap();
707        assert_eq!(exec.hook_result.unwrap().verdict, Verdict::Continue as u32);
708    }
709
710    /// WAT: calls host_get_link_state with 16-byte hash at 0x3000.
711    /// If state == 2 (Active) → Drop, else Continue.
712    const WAT_LINK_STATE: &str = r#"
713        (module
714            (import "env" "host_get_link_state" (func $link_state (param i32) (result i32)))
715            (memory (export "memory") 1)
716            (func (export "__rns_abi_version") (result i32) (i32.const 1))
717            (func (export "on_hook") (param $ctx_ptr i32) (result i32)
718                (if (i32.eq (call $link_state (i32.const 0x3000)) (i32.const 2))
719                    (then
720                        (i32.store (i32.const 0x2000) (i32.const 1)) ;; Drop
721                    )
722                    (else
723                        (i32.store (i32.const 0x2000) (i32.const 0)) ;; Continue
724                    )
725                )
726                (i32.store (i32.add (i32.const 0x2000) (i32.const 4)) (i32.const 0))
727                (i32.store (i32.add (i32.const 0x2000) (i32.const 8)) (i32.const 0))
728                (i32.store (i32.add (i32.const 0x2000) (i32.const 12)) (i32.const 0))
729                (i32.store (i32.add (i32.const 0x2000) (i32.const 16)) (i32.const 0))
730                (i32.store (i32.add (i32.const 0x2000) (i32.const 20)) (i32.const 0))
731                (i32.store (i32.add (i32.const 0x2000) (i32.const 24)) (i32.const 0))
732                (i32.const 0x2000)
733            )
734        )
735    "#;
736
737    #[test]
738    fn host_get_link_state_active() {
739        // link_state returns Some(2) (Active) → Drop
740        let engine = MockEngineCustom { announce_rate_val: None, link_state_val: Some(2) };
741        let mgr = make_manager();
742        let mut prog = mgr.compile("linkst".into(), WAT_LINK_STATE.as_bytes(), 0).unwrap();
743        let ctx = HookContext::Tick;
744        let exec = mgr.execute_program(&mut prog, &ctx, &engine, 0.0, None).unwrap();
745        assert!(exec.hook_result.unwrap().is_drop());
746    }
747
748    #[test]
749    fn host_get_link_state_not_found() {
750        // link_state returns None → -1, which != 2 → Continue
751        let engine = MockEngineCustom { announce_rate_val: None, link_state_val: None };
752        let mgr = make_manager();
753        let mut prog = mgr.compile("linkst".into(), WAT_LINK_STATE.as_bytes(), 0).unwrap();
754        let ctx = HookContext::Tick;
755        let exec = mgr.execute_program(&mut prog, &ctx, &engine, 0.0, None).unwrap();
756        assert_eq!(exec.hook_result.unwrap().verdict, Verdict::Continue as u32);
757    }
758
759    /// WAT: writes a SendOnInterface ActionWire at 0x3000 and calls host_inject_action.
760    /// Binary: tag=0 (SendOnInterface), interface=1 (u64 LE), data_offset=0x3100, data_len=4
761    /// Data at 0x3100: [0xDE, 0xAD, 0xBE, 0xEF]
762    /// Returns Continue.
763    const WAT_INJECT_ACTION: &str = r#"
764        (module
765            (import "env" "host_inject_action" (func $inject (param i32 i32) (result i32)))
766            (memory (export "memory") 1)
767            (func (export "__rns_abi_version") (result i32) (i32.const 1))
768            (func (export "on_hook") (param $ctx_ptr i32) (result i32)
769                ;; Write the data payload at 0x3100
770                (i32.store8 (i32.const 0x3100) (i32.const 0xDE))
771                (i32.store8 (i32.const 0x3101) (i32.const 0xAD))
772                (i32.store8 (i32.const 0x3102) (i32.const 0xBE))
773                (i32.store8 (i32.const 0x3103) (i32.const 0xEF))
774
775                ;; Write ActionWire at 0x3000:
776                ;; byte 0: tag = 0 (SendOnInterface)
777                (i32.store8 (i32.const 0x3000) (i32.const 0))
778                ;; bytes 1-8: interface = 1 (u64 LE)
779                (i64.store (i32.const 0x3001) (i64.const 1))
780                ;; bytes 9-12: data_offset = 0x3100 (u32 LE)
781                (i32.store (i32.const 0x3009) (i32.const 0x3100))
782                ;; bytes 13-16: data_len = 4 (u32 LE)
783                (i32.store (i32.const 0x300D) (i32.const 4))
784
785                ;; Call inject: ptr=0x3000, len=17 (1 + 8 + 4 + 4)
786                (drop (call $inject (i32.const 0x3000) (i32.const 17)))
787
788                ;; Return Continue
789                (i32.store (i32.const 0x2000) (i32.const 0))
790                (i32.store (i32.add (i32.const 0x2000) (i32.const 4)) (i32.const 0))
791                (i32.store (i32.add (i32.const 0x2000) (i32.const 8)) (i32.const 0))
792                (i32.store (i32.add (i32.const 0x2000) (i32.const 12)) (i32.const 0))
793                (i32.store (i32.add (i32.const 0x2000) (i32.const 16)) (i32.const 0))
794                (i32.store (i32.add (i32.const 0x2000) (i32.const 20)) (i32.const 0))
795                (i32.store (i32.add (i32.const 0x2000) (i32.const 24)) (i32.const 0))
796                (i32.const 0x2000)
797            )
798        )
799    "#;
800
801    #[test]
802    fn host_inject_action_send() {
803        let mgr = make_manager();
804        let mut prog = mgr.compile("inject".into(), WAT_INJECT_ACTION.as_bytes(), 0).unwrap();
805        let ctx = HookContext::Tick;
806        let exec = mgr.execute_program(&mut prog, &ctx, &NullEngine, 0.0, None).unwrap();
807        assert_eq!(exec.hook_result.unwrap().verdict, Verdict::Continue as u32);
808        assert_eq!(exec.injected_actions.len(), 1);
809        match &exec.injected_actions[0] {
810            crate::wire::ActionWire::SendOnInterface { interface, raw } => {
811                assert_eq!(*interface, 1);
812                assert_eq!(raw, &[0xDE, 0xAD, 0xBE, 0xEF]);
813            }
814            other => panic!("expected SendOnInterface, got {:?}", other),
815        }
816    }
817
818    /// WAT: returns Modify (verdict=2) with modified data at 0x2100 (4 bytes).
819    const WAT_MODIFY: &str = r#"
820        (module
821            (memory (export "memory") 1)
822            (func (export "__rns_abi_version") (result i32) (i32.const 1))
823            (func (export "on_hook") (param $ctx_ptr i32) (result i32)
824                ;; Write modified data at 0x2100
825                (i32.store8 (i32.const 0x2100) (i32.const 0xAA))
826                (i32.store8 (i32.const 0x2101) (i32.const 0xBB))
827                (i32.store8 (i32.const 0x2102) (i32.const 0xCC))
828                (i32.store8 (i32.const 0x2103) (i32.const 0xDD))
829
830                ;; verdict = 2 (Modify)
831                (i32.store (i32.const 0x2000) (i32.const 2))
832                ;; modified_data_offset = 0x2100
833                (i32.store (i32.add (i32.const 0x2000) (i32.const 4)) (i32.const 0x2100))
834                ;; modified_data_len = 4
835                (i32.store (i32.add (i32.const 0x2000) (i32.const 8)) (i32.const 4))
836                (i32.store (i32.add (i32.const 0x2000) (i32.const 12)) (i32.const 0))
837                (i32.store (i32.add (i32.const 0x2000) (i32.const 16)) (i32.const 0))
838                (i32.store (i32.add (i32.const 0x2000) (i32.const 20)) (i32.const 0))
839                (i32.store (i32.add (i32.const 0x2000) (i32.const 24)) (i32.const 0))
840                (i32.const 0x2000)
841            )
842        )
843    "#;
844
845    #[test]
846    fn modify_extracts_data() {
847        let mgr = make_manager();
848        let mut prog = mgr.compile("mod".into(), WAT_MODIFY.as_bytes(), 0).unwrap();
849        let ctx = HookContext::Tick;
850        let exec = mgr.execute_program(&mut prog, &ctx, &NullEngine, 0.0, None).unwrap();
851        let r = exec.hook_result.unwrap();
852        assert_eq!(r.verdict, Verdict::Modify as u32);
853        let data = exec.modified_data.unwrap();
854        assert_eq!(data, vec![0xAA, 0xBB, 0xCC, 0xDD]);
855    }
856
857    #[test]
858    fn chain_accumulates_injected_actions() {
859        // Chain: inject_action module (Continue) + Drop module
860        // Both should contribute to the result; injected actions should be accumulated
861        let mgr = make_manager();
862        let injector = mgr.compile("injector".into(), WAT_INJECT_ACTION.as_bytes(), 100).unwrap();
863        let dropper = mgr.compile("dropper".into(), WAT_DROP.as_bytes(), 0).unwrap();
864        let mut programs = vec![injector, dropper];
865        programs.sort_by(|a, b| b.priority.cmp(&a.priority));
866
867        let ctx = HookContext::Tick;
868        let exec = mgr.run_chain(&mut programs, &ctx, &NullEngine, 0.0).unwrap();
869        // Chain should drop (second hook)
870        assert!(exec.hook_result.unwrap().is_drop());
871        // But injected action from first hook should be present
872        assert_eq!(exec.injected_actions.len(), 1);
873    }
874
875    // --- Instance persistence tests ---
876
877    /// WAT module with a mutable global counter. Each call increments it and
878    /// writes the counter value into the verdict field (abusing it as an integer).
879    /// This lets us verify that the global persists across calls.
880    const WAT_COUNTER: &str = r#"
881        (module
882            (memory (export "memory") 1)
883            (func (export "__rns_abi_version") (result i32) (i32.const 1))
884            (global $counter (mut i32) (i32.const 0))
885            (func (export "on_hook") (param i32) (result i32)
886                ;; Increment counter
887                (global.set $counter (i32.add (global.get $counter) (i32.const 1)))
888                ;; Write counter value at 0x3000 (scratch area)
889                (i32.store (i32.const 0x3000) (global.get $counter))
890                ;; Return Continue with the counter stashed in modified_data region
891                ;; verdict = 2 (Modify) so we can extract the counter via modified_data
892                (i32.store (i32.const 0x2000) (i32.const 2))
893                ;; modified_data_offset = 0x3000
894                (i32.store (i32.add (i32.const 0x2000) (i32.const 4)) (i32.const 0x3000))
895                ;; modified_data_len = 4
896                (i32.store (i32.add (i32.const 0x2000) (i32.const 8)) (i32.const 4))
897                (i32.store (i32.add (i32.const 0x2000) (i32.const 12)) (i32.const 0))
898                (i32.store (i32.add (i32.const 0x2000) (i32.const 16)) (i32.const 0))
899                (i32.store (i32.add (i32.const 0x2000) (i32.const 20)) (i32.const 0))
900                (i32.store (i32.add (i32.const 0x2000) (i32.const 24)) (i32.const 0))
901                (i32.const 0x2000)
902            )
903        )
904    "#;
905
906    fn extract_counter(exec: &ExecuteResult) -> u32 {
907        let data = exec.modified_data.as_ref().expect("no modified data");
908        assert_eq!(data.len(), 4);
909        u32::from_le_bytes([data[0], data[1], data[2], data[3]])
910    }
911
912    #[test]
913    fn instance_persistence_counter() {
914        let mgr = make_manager();
915        let mut prog = mgr.compile("counter".into(), WAT_COUNTER.as_bytes(), 0).unwrap();
916        let ctx = HookContext::Tick;
917
918        // Call 3 times — counter should increment across calls
919        let exec1 = mgr.execute_program(&mut prog, &ctx, &NullEngine, 0.0, None).unwrap();
920        assert_eq!(extract_counter(&exec1), 1);
921
922        let exec2 = mgr.execute_program(&mut prog, &ctx, &NullEngine, 0.0, None).unwrap();
923        assert_eq!(extract_counter(&exec2), 2);
924
925        let exec3 = mgr.execute_program(&mut prog, &ctx, &NullEngine, 0.0, None).unwrap();
926        assert_eq!(extract_counter(&exec3), 3);
927    }
928
929    #[test]
930    fn instance_persistence_resets_on_drop_cache() {
931        let mgr = make_manager();
932        let mut prog = mgr.compile("counter".into(), WAT_COUNTER.as_bytes(), 0).unwrap();
933        let ctx = HookContext::Tick;
934
935        // Increment twice
936        mgr.execute_program(&mut prog, &ctx, &NullEngine, 0.0, None).unwrap();
937        let exec2 = mgr.execute_program(&mut prog, &ctx, &NullEngine, 0.0, None).unwrap();
938        assert_eq!(extract_counter(&exec2), 2);
939
940        // Drop cache (simulates reload)
941        prog.drop_cache();
942
943        // Counter should restart at 1
944        let exec3 = mgr.execute_program(&mut prog, &ctx, &NullEngine, 0.0, None).unwrap();
945        assert_eq!(extract_counter(&exec3), 1);
946    }
947
948    // --- ABI version validation tests ---
949
950    /// WAT module without __rns_abi_version export.
951    const WAT_NO_ABI_VERSION: &str = r#"
952        (module
953            (memory (export "memory") 1)
954            (func (export "on_hook") (param i32) (result i32)
955                (i32.store (i32.const 0x2000) (i32.const 0))
956                (i32.store (i32.add (i32.const 0x2000) (i32.const 4)) (i32.const 0))
957                (i32.store (i32.add (i32.const 0x2000) (i32.const 8)) (i32.const 0))
958                (i32.store (i32.add (i32.const 0x2000) (i32.const 12)) (i32.const 0))
959                (i32.store (i32.add (i32.const 0x2000) (i32.const 16)) (i32.const 0))
960                (i32.store (i32.add (i32.const 0x2000) (i32.const 20)) (i32.const 0))
961                (i32.store (i32.add (i32.const 0x2000) (i32.const 24)) (i32.const 0))
962                (i32.const 0x2000)
963            )
964        )
965    "#;
966
967    /// WAT module with wrong ABI version (9999).
968    const WAT_WRONG_ABI_VERSION: &str = r#"
969        (module
970            (memory (export "memory") 1)
971            (func (export "__rns_abi_version") (result i32) (i32.const 9999))
972            (func (export "on_hook") (param i32) (result i32)
973                (i32.store (i32.const 0x2000) (i32.const 0))
974                (i32.store (i32.add (i32.const 0x2000) (i32.const 4)) (i32.const 0))
975                (i32.store (i32.add (i32.const 0x2000) (i32.const 8)) (i32.const 0))
976                (i32.store (i32.add (i32.const 0x2000) (i32.const 12)) (i32.const 0))
977                (i32.store (i32.add (i32.const 0x2000) (i32.const 16)) (i32.const 0))
978                (i32.store (i32.add (i32.const 0x2000) (i32.const 20)) (i32.const 0))
979                (i32.store (i32.add (i32.const 0x2000) (i32.const 24)) (i32.const 0))
980                (i32.const 0x2000)
981            )
982        )
983    "#;
984
985    #[test]
986    fn rejects_missing_abi_version() {
987        let mgr = make_manager();
988        let result = mgr.compile("no_abi".into(), WAT_NO_ABI_VERSION.as_bytes(), 0);
989        match result {
990            Err(HookError::AbiVersionMismatch { hook_name, expected, found }) => {
991                assert_eq!(hook_name, "no_abi");
992                assert_eq!(expected, HOST_ABI_VERSION);
993                assert_eq!(found, None);
994            }
995            other => panic!("expected AbiVersionMismatch with found=None, got {:?}", other.err()),
996        }
997    }
998
999    #[test]
1000    fn rejects_wrong_abi_version() {
1001        let mgr = make_manager();
1002        let result = mgr.compile("bad_abi".into(), WAT_WRONG_ABI_VERSION.as_bytes(), 0);
1003        match result {
1004            Err(HookError::AbiVersionMismatch { hook_name, expected, found }) => {
1005                assert_eq!(hook_name, "bad_abi");
1006                assert_eq!(expected, HOST_ABI_VERSION);
1007                assert_eq!(found, Some(9999));
1008            }
1009            other => panic!("expected AbiVersionMismatch with found=Some(9999), got {:?}", other.err()),
1010        }
1011    }
1012
1013    #[test]
1014    fn accepts_correct_abi_version() {
1015        let mgr = make_manager();
1016        let result = mgr.compile("good_abi".into(), WAT_CONTINUE.as_bytes(), 0);
1017        assert!(result.is_ok(), "compile should succeed with correct ABI version");
1018    }
1019}