Skip to main content

rns_hooks/
manager.rs

1#[cfg(any(feature = "wasm", feature = "native"))]
2use crate::arena;
3use crate::engine_access::EngineAccess;
4#[cfg(feature = "wasm")]
5use crate::engine_access::NullEngine;
6use crate::error::HookError;
7use crate::hooks::HookContext;
8#[cfg(feature = "wasm")]
9use crate::host_fns;
10use crate::program::{LoadedProgram, ProgramBackend};
11use crate::result::{ExecuteResult, HookResult, Verdict};
12#[cfg(feature = "wasm")]
13use crate::runtime::{StoreData, WasmRuntime};
14#[cfg(feature = "wasm")]
15use wasmtime::{Linker, Store};
16
17/// ABI version the host expects from compiled hook modules.
18#[cfg(feature = "wasm")]
19const HOST_ABI_VERSION: i32 = rns_hooks_abi::ABI_VERSION;
20
21/// Central manager for hook execution.
22///
23/// Owns any enabled backend runtime state. Programs are stored in `HookSlot`s
24/// (one per hook point); the manager provides execution.
25pub struct HookManager {
26    #[cfg(feature = "wasm")]
27    runtime: WasmRuntime,
28    #[cfg(feature = "wasm")]
29    linker: Linker<StoreData>,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum HookBackend {
34    Wasm,
35    Native,
36    Builtin,
37}
38
39impl HookBackend {
40    pub fn as_str(self) -> &'static str {
41        match self {
42            HookBackend::Wasm => "wasm",
43            HookBackend::Native => "native",
44            HookBackend::Builtin => "builtin",
45        }
46    }
47}
48
49impl std::str::FromStr for HookBackend {
50    type Err = String;
51
52    fn from_str(value: &str) -> Result<Self, Self::Err> {
53        match value.to_ascii_lowercase().as_str() {
54            "wasm" => Ok(HookBackend::Wasm),
55            "native" | "dylib" | "dynamic" => Ok(HookBackend::Native),
56            "builtin" | "built-in" | "static" => Ok(HookBackend::Builtin),
57            other => Err(format!("unknown hook type '{}'", other)),
58        }
59    }
60}
61
62impl HookManager {
63    pub fn new() -> Result<Self, HookError> {
64        #[cfg(feature = "wasm")]
65        {
66            let runtime = WasmRuntime::new().map_err(|e| HookError::CompileError(e.to_string()))?;
67            let mut linker = Linker::new(runtime.engine());
68            host_fns::register_host_functions(&mut linker)
69                .map_err(|e| HookError::CompileError(e.to_string()))?;
70            Ok(HookManager { runtime, linker })
71        }
72        #[cfg(not(feature = "wasm"))]
73        {
74            Ok(HookManager {})
75        }
76    }
77
78    /// Compile WASM bytes into a LoadedProgram.
79    ///
80    /// Validates that the module exports `__rns_abi_version` returning the
81    /// expected ABI version before accepting it.
82    pub fn compile(
83        &self,
84        name: String,
85        bytes: &[u8],
86        priority: i32,
87    ) -> Result<LoadedProgram, HookError> {
88        #[cfg(not(feature = "wasm"))]
89        {
90            let _ = (name, bytes, priority);
91            return Err(HookError::CompileError(
92                "WASM hook backend not enabled".to_string(),
93            ));
94        }
95        #[cfg(feature = "wasm")]
96        {
97            let module = self
98                .runtime
99                .compile(bytes)
100                .map_err(|e| HookError::CompileError(e.to_string()))?;
101            self.validate_abi_version(&name, &module)?;
102            Ok(LoadedProgram::new(name, module, priority))
103        }
104    }
105
106    /// Check that the module exports `__rns_abi_version() -> i32` and that
107    /// the returned value matches [`HOST_ABI_VERSION`].
108    #[cfg(feature = "wasm")]
109    fn validate_abi_version(&self, name: &str, module: &wasmtime::Module) -> Result<(), HookError> {
110        // Check if the export exists in the module's type information.
111        let has_export = module.exports().any(|e| e.name() == "__rns_abi_version");
112        if !has_export {
113            return Err(HookError::AbiVersionMismatch {
114                hook_name: name.to_string(),
115                expected: HOST_ABI_VERSION,
116                found: None,
117            });
118        }
119
120        // Instantiate the module to call the function and read the version.
121        static NULL_ENGINE: NullEngine = NullEngine;
122        let mut store = Store::new(
123            self.runtime.engine(),
124            StoreData {
125                engine_access: &NULL_ENGINE as *const dyn EngineAccess,
126                now: 0.0,
127                injected_actions: Vec::new(),
128                log_messages: Vec::new(),
129                provider_events: Vec::new(),
130                provider_events_enabled: false,
131            },
132        );
133        store
134            .set_fuel(self.runtime.fuel())
135            .map_err(|e| HookError::CompileError(e.to_string()))?;
136
137        let instance = self
138            .linker
139            .instantiate(&mut store, module)
140            .map_err(|e| HookError::InstantiationError(e.to_string()))?;
141
142        let func = instance
143            .get_typed_func::<(), i32>(&mut store, "__rns_abi_version")
144            .map_err(|e| {
145                HookError::CompileError(format!("__rns_abi_version has wrong signature: {}", e))
146            })?;
147
148        let version = func
149            .call(&mut store, ())
150            .map_err(|e| HookError::Trap(format!("__rns_abi_version trapped: {}", e)))?;
151
152        if version != HOST_ABI_VERSION {
153            return Err(HookError::AbiVersionMismatch {
154                hook_name: name.to_string(),
155                expected: HOST_ABI_VERSION,
156                found: Some(version),
157            });
158        }
159
160        Ok(())
161    }
162
163    /// Compile a WASM file from disk.
164    pub fn load_file(
165        &self,
166        name: String,
167        path: &std::path::Path,
168        priority: i32,
169    ) -> Result<LoadedProgram, HookError> {
170        let bytes = std::fs::read(path)?;
171        self.compile(name, &bytes, priority)
172    }
173
174    pub fn load_file_backend(
175        &self,
176        name: String,
177        path: &std::path::Path,
178        priority: i32,
179        backend: HookBackend,
180    ) -> Result<LoadedProgram, HookError> {
181        match backend {
182            HookBackend::Wasm => self.load_file(name, path, priority),
183            HookBackend::Builtin => Err(HookError::CompileError(
184                "built-in hooks are loaded by ID, not file path".to_string(),
185            )),
186            HookBackend::Native => {
187                #[cfg(feature = "native")]
188                {
189                    let native = crate::native::NativeProgram::load(path)?;
190                    Ok(LoadedProgram::new_native(name, native, priority))
191                }
192                #[cfg(not(feature = "native"))]
193                {
194                    let _ = (name, path, priority);
195                    Err(HookError::CompileError(
196                        "native hook backend not enabled".to_string(),
197                    ))
198                }
199            }
200        }
201    }
202
203    pub fn load_builtin(
204        &self,
205        name: String,
206        id: impl Into<String>,
207        priority: i32,
208    ) -> Result<LoadedProgram, HookError> {
209        let builtin = crate::builtin::BuiltinProgram::load(id)?;
210        Ok(LoadedProgram::new_builtin(name, builtin, priority))
211    }
212
213    /// Execute a single program against a hook context. Returns an `ExecuteResult`
214    /// containing the hook result, any injected actions, and modified data (all
215    /// extracted from WASM memory before the store is dropped). Returns `None`
216    /// on trap/fuel exhaustion (fail-open).
217    ///
218    /// If `data_override` is provided (from a previous Modify verdict in a chain),
219    /// it replaces the packet data region in the arena after writing the context.
220    ///
221    /// The store and instance are cached in the program for cross-call state
222    /// persistence (WASM linear memory survives across invocations). On each call
223    /// we reset fuel and per-call StoreData fields but keep the WASM globals and
224    /// memory intact.
225    pub fn execute_program(
226        &self,
227        program: &mut LoadedProgram,
228        ctx: &HookContext,
229        engine_access: &dyn EngineAccess,
230        now: f64,
231        data_override: Option<&[u8]>,
232    ) -> Option<ExecuteResult> {
233        self.execute_program_with_provider_events(
234            program,
235            ctx,
236            engine_access,
237            now,
238            false,
239            data_override,
240        )
241    }
242
243    pub fn execute_program_with_provider_events(
244        &self,
245        program: &mut LoadedProgram,
246        ctx: &HookContext,
247        engine_access: &dyn EngineAccess,
248        now: f64,
249        provider_events_enabled: bool,
250        data_override: Option<&[u8]>,
251    ) -> Option<ExecuteResult> {
252        if !program.enabled {
253            return None;
254        }
255
256        if matches!(program.backend, ProgramBackend::Builtin(_)) {
257            let result = match &program.backend {
258                ProgramBackend::Builtin(builtin) => builtin.execute(
259                    ctx,
260                    data_override,
261                    engine_access,
262                    now,
263                    provider_events_enabled,
264                ),
265                #[cfg(feature = "wasm")]
266                ProgramBackend::Wasm(_) => unreachable!(),
267                #[cfg(feature = "native")]
268                ProgramBackend::Native(_) => unreachable!(),
269            };
270            return match result {
271                Ok(mut exec) => {
272                    program.record_success();
273                    for event in &mut exec.provider_events {
274                        event.hook_name = program.name.clone();
275                    }
276                    Some(exec)
277                }
278                Err(e) => {
279                    let auto_disabled = program.record_trap();
280                    if auto_disabled {
281                        log::error!(
282                            "built-in hook '{}' auto-disabled after {} consecutive errors",
283                            program.name,
284                            program.consecutive_traps
285                        );
286                    } else {
287                        log::warn!("built-in hook '{}' failed: {}", program.name, e);
288                    }
289                    None
290                }
291            };
292        }
293
294        #[cfg(feature = "native")]
295        if matches!(program.backend, ProgramBackend::Native(_)) {
296            let ctx_bytes = match arena::context_to_bytes(ctx, data_override) {
297                Ok(bytes) => bytes,
298                Err(e) => {
299                    log::warn!(
300                        "failed to encode native hook context '{}': {}",
301                        program.name,
302                        e
303                    );
304                    program.record_trap();
305                    return None;
306                }
307            };
308            let result = match &program.backend {
309                ProgramBackend::Native(native) => {
310                    native.execute(&ctx_bytes, engine_access, provider_events_enabled)
311                }
312                ProgramBackend::Builtin(_) => unreachable!(),
313                #[cfg(feature = "wasm")]
314                ProgramBackend::Wasm(_) => unreachable!(),
315            };
316            return match result {
317                Ok(mut exec) => {
318                    program.record_success();
319                    for event in &mut exec.provider_events {
320                        event.hook_name = program.name.clone();
321                    }
322                    Some(exec)
323                }
324                Err(e) => {
325                    let auto_disabled = program.record_trap();
326                    if auto_disabled {
327                        log::error!(
328                            "native hook '{}' auto-disabled after {} consecutive errors",
329                            program.name,
330                            program.consecutive_traps
331                        );
332                    } else {
333                        log::warn!("native hook '{}' failed: {}", program.name, e);
334                    }
335                    None
336                }
337            };
338        }
339
340        #[cfg(not(feature = "wasm"))]
341        {
342            let _ = (
343                ctx,
344                engine_access,
345                now,
346                provider_events_enabled,
347                data_override,
348            );
349            return None;
350        }
351
352        #[cfg(feature = "wasm")]
353        {
354            self.execute_wasm_program(
355                program,
356                ctx,
357                engine_access,
358                now,
359                provider_events_enabled,
360                data_override,
361            )
362        }
363    }
364
365    #[cfg(feature = "wasm")]
366    fn execute_wasm_program(
367        &self,
368        program: &mut LoadedProgram,
369        ctx: &HookContext,
370        engine_access: &dyn EngineAccess,
371        now: f64,
372        provider_events_enabled: bool,
373        data_override: Option<&[u8]>,
374    ) -> Option<ExecuteResult> {
375        // Safety: transmute erases the lifetime on the fat pointer. The pointer
376        // is only dereferenced during this function call, while the borrow is valid.
377        let engine_access_ptr: *const dyn EngineAccess =
378            unsafe { std::mem::transmute(engine_access as *const dyn EngineAccess) };
379
380        // Take the cached store+instance out of program (or create fresh).
381        // We take ownership to avoid borrow-checker conflicts with program.record_*().
382        let cached = match &mut program.backend {
383            ProgramBackend::Builtin(_) => unreachable!(),
384            ProgramBackend::Wasm(wasm) => wasm.cached.take(),
385            #[cfg(feature = "native")]
386            ProgramBackend::Native(_) => unreachable!(),
387        };
388        let (mut store, instance) = if let Some(cached) = cached {
389            let (mut s, i) = cached;
390            // Reset per-call state: fuel, engine_access, injected_actions, log_messages
391            s.data_mut()
392                .reset_per_call(engine_access_ptr, now, provider_events_enabled);
393            if let Err(e) = s.set_fuel(self.runtime.fuel()) {
394                log::warn!("failed to set fuel for hook '{}': {}", program.name, e);
395                self.cache_wasm_instance(program, s, i);
396                return None;
397            }
398            (s, i)
399        } else {
400            let store_data = StoreData {
401                engine_access: engine_access_ptr,
402                now,
403                injected_actions: Vec::new(),
404                log_messages: Vec::new(),
405                provider_events: Vec::new(),
406                provider_events_enabled,
407            };
408
409            let mut store = Store::new(self.runtime.engine(), store_data);
410            if let Err(e) = store.set_fuel(self.runtime.fuel()) {
411                log::warn!("failed to set fuel for hook '{}': {}", program.name, e);
412                return None;
413            }
414
415            let module = match &program.backend {
416                ProgramBackend::Builtin(_) => unreachable!(),
417                ProgramBackend::Wasm(wasm) => wasm.module.clone(),
418                #[cfg(feature = "native")]
419                ProgramBackend::Native(_) => unreachable!(),
420            };
421            let instance = match self.linker.instantiate(&mut store, &module) {
422                Ok(inst) => inst,
423                Err(e) => {
424                    log::warn!("failed to instantiate hook '{}': {}", program.name, e);
425                    program.record_trap();
426                    return None;
427                }
428            };
429
430            (store, instance)
431        };
432
433        // Write context into guest memory
434        let memory = match instance.get_memory(&mut store, "memory") {
435            Some(mem) => mem,
436            None => {
437                log::warn!("hook '{}' has no exported memory", program.name);
438                program.record_trap();
439                self.cache_wasm_instance(program, store, instance);
440                return None;
441            }
442        };
443
444        if let Err(e) = arena::write_context(&memory, &mut store, ctx) {
445            log::warn!("failed to write context for hook '{}': {}", program.name, e);
446            program.record_trap();
447            self.cache_wasm_instance(program, store, instance);
448            return None;
449        }
450
451        // If a previous hook in the chain returned Modify, override the packet data
452        if let Some(override_data) = data_override {
453            if let Err(e) = arena::write_data_override(&memory, &mut store, override_data) {
454                log::warn!(
455                    "failed to write data override for hook '{}': {}",
456                    program.name,
457                    e
458                );
459                // Non-fatal: continue with original data
460            }
461        }
462
463        // Call the exported hook function
464        let export_name = match &program.backend {
465            ProgramBackend::Builtin(_) => unreachable!(),
466            ProgramBackend::Wasm(wasm) => wasm.export_name.clone(),
467            #[cfg(feature = "native")]
468            ProgramBackend::Native(_) => unreachable!(),
469        };
470        let func = match instance.get_typed_func::<i32, i32>(&mut store, &export_name) {
471            Ok(f) => f,
472            Err(e) => {
473                log::warn!(
474                    "hook '{}' missing export '{}': {}",
475                    program.name,
476                    export_name,
477                    e
478                );
479                program.record_trap();
480                self.cache_wasm_instance(program, store, instance);
481                return None;
482            }
483        };
484
485        let result_offset = match func.call(&mut store, arena::ARENA_BASE as i32) {
486            Ok(offset) => offset,
487            Err(e) => {
488                // Fail-open: trap or fuel exhaustion → continue
489                let auto_disabled = program.record_trap();
490                if auto_disabled {
491                    log::error!(
492                        "hook '{}' auto-disabled after {} consecutive traps",
493                        program.name,
494                        program.consecutive_traps
495                    );
496                } else {
497                    log::warn!("hook '{}' trapped: {}", program.name, e);
498                }
499                self.cache_wasm_instance(program, store, instance);
500                return None;
501            }
502        };
503
504        // Read result from guest memory
505        let ret = match arena::read_result(&memory, &store, result_offset as usize) {
506            Ok(result) => {
507                program.record_success();
508
509                // Extract modified data from WASM memory
510                let modified_data = if Verdict::from_u32(result.verdict) == Some(Verdict::Modify) {
511                    arena::read_modified_data(&memory, &store, &result)
512                } else {
513                    None
514                };
515
516                // Extract injected actions from the store
517                let injected_actions = std::mem::take(&mut store.data_mut().injected_actions);
518                let provider_events = std::mem::take(&mut store.data_mut().provider_events)
519                    .into_iter()
520                    .map(|event| crate::result::EmittedProviderEvent {
521                        hook_name: program.name.clone(),
522                        payload_type: event.payload_type,
523                        payload: event.payload,
524                    })
525                    .collect();
526
527                Some(ExecuteResult {
528                    hook_result: Some(result),
529                    injected_actions,
530                    provider_events,
531                    modified_data,
532                })
533            }
534            Err(e) => {
535                log::warn!("hook '{}' returned invalid result: {}", program.name, e);
536                program.record_trap();
537                None
538            }
539        };
540
541        // Put the store+instance back for next call
542        self.cache_wasm_instance(program, store, instance);
543        ret
544    }
545
546    #[cfg(feature = "wasm")]
547    fn cache_wasm_instance(
548        &self,
549        program: &mut LoadedProgram,
550        store: Store<StoreData>,
551        instance: wasmtime::Instance,
552    ) {
553        match &mut program.backend {
554            ProgramBackend::Builtin(_) => unreachable!(),
555            ProgramBackend::Wasm(wasm) => wasm.cached = Some((store, instance)),
556            #[cfg(feature = "native")]
557            ProgramBackend::Native(_) => unreachable!(),
558        }
559    }
560
561    /// Run a chain of programs. Stops on Drop or Halt, continues on Continue or Modify.
562    /// Returns an `ExecuteResult` accumulating all injected actions across the chain
563    /// and the last meaningful hook result (Drop/Halt/Modify), or None if all continued.
564    ///
565    /// When a hook returns Modify with modified data, subsequent hooks in the chain
566    /// receive the modified data (only applicable to Packet contexts).
567    pub fn run_chain(
568        &self,
569        programs: &mut [LoadedProgram],
570        ctx: &HookContext,
571        engine_access: &dyn EngineAccess,
572        now: f64,
573    ) -> Option<ExecuteResult> {
574        self.run_chain_with_provider_events(programs, ctx, engine_access, now, false)
575    }
576
577    pub fn run_chain_with_provider_events(
578        &self,
579        programs: &mut [LoadedProgram],
580        ctx: &HookContext,
581        engine_access: &dyn EngineAccess,
582        now: f64,
583        provider_events_enabled: bool,
584    ) -> Option<ExecuteResult> {
585        let mut accumulated_actions = Vec::new();
586        let mut accumulated_provider_events = Vec::new();
587        let mut last_result: Option<HookResult> = None;
588        let mut last_modified_data: Option<Vec<u8>> = None;
589        let is_packet_ctx = matches!(ctx, HookContext::Packet { .. });
590
591        for program in programs.iter_mut() {
592            if !program.enabled {
593                continue;
594            }
595            let override_ref = if is_packet_ctx {
596                last_modified_data.as_deref()
597            } else {
598                None
599            };
600            if let Some(exec_result) = self.execute_program_with_provider_events(
601                program,
602                ctx,
603                engine_access,
604                now,
605                provider_events_enabled,
606                override_ref,
607            ) {
608                accumulated_actions.extend(exec_result.injected_actions);
609                accumulated_provider_events.extend(exec_result.provider_events);
610
611                if let Some(ref result) = exec_result.hook_result {
612                    let verdict = Verdict::from_u32(result.verdict);
613                    match verdict {
614                        Some(Verdict::Drop) | Some(Verdict::Halt) => {
615                            return Some(ExecuteResult {
616                                hook_result: exec_result.hook_result,
617                                injected_actions: accumulated_actions,
618                                provider_events: accumulated_provider_events,
619                                modified_data: exec_result.modified_data.or(last_modified_data),
620                            });
621                        }
622                        Some(Verdict::Modify) => {
623                            last_result = exec_result.hook_result;
624                            if is_packet_ctx {
625                                if let Some(data) = exec_result.modified_data {
626                                    last_modified_data = Some(data);
627                                }
628                            }
629                        }
630                        _ => {} // Continue → keep going
631                    }
632                }
633            }
634        }
635
636        if last_result.is_some()
637            || !accumulated_actions.is_empty()
638            || !accumulated_provider_events.is_empty()
639        {
640            Some(ExecuteResult {
641                hook_result: last_result,
642                injected_actions: accumulated_actions,
643                provider_events: accumulated_provider_events,
644                modified_data: last_modified_data,
645            })
646        } else {
647            None
648        }
649    }
650}
651
652#[cfg(all(test, feature = "wasm"))]
653mod tests {
654    use super::*;
655    use crate::engine_access::NullEngine;
656
657    fn make_manager() -> HookManager {
658        HookManager::new().expect("failed to create HookManager")
659    }
660
661    /// WAT module that returns Continue (verdict=0).
662    const WAT_CONTINUE: &str = r#"
663        (module
664            (memory (export "memory") 1)
665            (func (export "__rns_abi_version") (result i32) (i32.const 1))
666            (func (export "on_hook") (param i32) (result i32)
667                ;; Write HookResult at offset 0x2000
668                ;; verdict = 0 (Continue)
669                (i32.store (i32.const 0x2000) (i32.const 0))
670                ;; modified_data_offset = 0
671                (i32.store (i32.add (i32.const 0x2000) (i32.const 4)) (i32.const 0))
672                ;; modified_data_len = 0
673                (i32.store (i32.add (i32.const 0x2000) (i32.const 8)) (i32.const 0))
674                ;; inject_actions_offset = 0
675                (i32.store (i32.add (i32.const 0x2000) (i32.const 12)) (i32.const 0))
676                ;; inject_actions_count = 0
677                (i32.store (i32.add (i32.const 0x2000) (i32.const 16)) (i32.const 0))
678                ;; log_offset = 0
679                (i32.store (i32.add (i32.const 0x2000) (i32.const 20)) (i32.const 0))
680                ;; log_len = 0
681                (i32.store (i32.add (i32.const 0x2000) (i32.const 24)) (i32.const 0))
682                (i32.const 0x2000)
683            )
684        )
685    "#;
686
687    /// WAT module that returns Drop (verdict=1).
688    const WAT_DROP: &str = r#"
689        (module
690            (memory (export "memory") 1)
691            (func (export "__rns_abi_version") (result i32) (i32.const 1))
692            (func (export "on_hook") (param i32) (result i32)
693                (i32.store (i32.const 0x2000) (i32.const 1))
694                (i32.store (i32.add (i32.const 0x2000) (i32.const 4)) (i32.const 0))
695                (i32.store (i32.add (i32.const 0x2000) (i32.const 8)) (i32.const 0))
696                (i32.store (i32.add (i32.const 0x2000) (i32.const 12)) (i32.const 0))
697                (i32.store (i32.add (i32.const 0x2000) (i32.const 16)) (i32.const 0))
698                (i32.store (i32.add (i32.const 0x2000) (i32.const 20)) (i32.const 0))
699                (i32.store (i32.add (i32.const 0x2000) (i32.const 24)) (i32.const 0))
700                (i32.const 0x2000)
701            )
702        )
703    "#;
704
705    /// WAT module that traps immediately.
706    const WAT_TRAP: &str = r#"
707        (module
708            (memory (export "memory") 1)
709            (func (export "__rns_abi_version") (result i32) (i32.const 1))
710            (func (export "on_hook") (param i32) (result i32)
711                unreachable
712            )
713        )
714    "#;
715
716    /// WAT module with infinite loop (will exhaust fuel).
717    const WAT_INFINITE: &str = r#"
718        (module
719            (memory (export "memory") 1)
720            (func (export "__rns_abi_version") (result i32) (i32.const 1))
721            (func (export "on_hook") (param i32) (result i32)
722                (loop $inf (br $inf))
723                (i32.const 0)
724            )
725        )
726    "#;
727
728    /// Exhausts fuel on the first call, then succeeds from the same cached
729    /// instance. This verifies both store recovery and per-call fuel reset.
730    const WAT_FUEL_RECOVERY: &str = r#"
731        (module
732            (memory (export "memory") 1)
733            (global $calls (mut i32) (i32.const 0))
734            (func (export "__rns_abi_version") (result i32) (i32.const 1))
735            (func (export "on_hook") (param i32) (result i32)
736                (global.set $calls (i32.add (global.get $calls) (i32.const 1)))
737                (if (i32.eq (global.get $calls) (i32.const 1))
738                    (then (loop $inf (br $inf))))
739                (i32.store (i32.const 0x2000) (i32.const 0))
740                (i32.store (i32.const 0x2004) (i32.const 0))
741                (i32.store (i32.const 0x2008) (i32.const 0))
742                (i32.store (i32.const 0x200c) (i32.const 0))
743                (i32.store (i32.const 0x2010) (i32.const 0))
744                (i32.store (i32.const 0x2014) (i32.const 0))
745                (i32.store (i32.const 0x2018) (i32.const 0))
746                (i32.const 0x2000))
747        )
748    "#;
749
750    /// Exercises Wasmtime's fuel-accounted bulk-memory implementation and
751    /// returns the copied bytes through the hook ABI.
752    const WAT_BULK_MEMORY: &str = r#"
753        (module
754            (memory (export "memory") 2)
755            (func (export "__rns_abi_version") (result i32) (i32.const 1))
756            (func (export "on_hook") (param i32) (result i32)
757                (memory.fill (i32.const 0x10000) (i32.const 0x5a) (i32.const 0x4000))
758                (memory.copy (i32.const 0x14000) (i32.const 0x10000) (i32.const 0x4000))
759                (i32.store (i32.const 0x2000) (i32.const 2))
760                (i32.store (i32.const 0x2004) (i32.const 0x14000))
761                (i32.store (i32.const 0x2008) (i32.const 0x4000))
762                (i32.store (i32.const 0x200c) (i32.const 0))
763                (i32.store (i32.const 0x2010) (i32.const 0))
764                (i32.store (i32.const 0x2014) (i32.const 0))
765                (i32.store (i32.const 0x2018) (i32.const 0))
766                (i32.const 0x2000))
767        )
768    "#;
769
770    /// WAT module that calls host_has_path and drops if path exists.
771    const WAT_HOST_HAS_PATH: &str = r#"
772        (module
773            (import "env" "host_has_path" (func $has_path (param i32) (result i32)))
774            (memory (export "memory") 1)
775            (func (export "__rns_abi_version") (result i32) (i32.const 1))
776            (func (export "on_hook") (param $ctx_ptr i32) (result i32)
777                ;; Check if path exists for a 16-byte dest at offset 0x3000
778                ;; (we'll write the dest hash there in the test)
779                (if (call $has_path (i32.const 0x3000))
780                    (then
781                        ;; Drop
782                        (i32.store (i32.const 0x2000) (i32.const 1))
783                    )
784                    (else
785                        ;; Continue
786                        (i32.store (i32.const 0x2000) (i32.const 0))
787                    )
788                )
789                (i32.store (i32.add (i32.const 0x2000) (i32.const 4)) (i32.const 0))
790                (i32.store (i32.add (i32.const 0x2000) (i32.const 8)) (i32.const 0))
791                (i32.store (i32.add (i32.const 0x2000) (i32.const 12)) (i32.const 0))
792                (i32.store (i32.add (i32.const 0x2000) (i32.const 16)) (i32.const 0))
793                (i32.store (i32.add (i32.const 0x2000) (i32.const 20)) (i32.const 0))
794                (i32.store (i32.add (i32.const 0x2000) (i32.const 24)) (i32.const 0))
795                (i32.const 0x2000)
796            )
797        )
798    "#;
799
800    #[test]
801    fn pass_through() {
802        let mgr = make_manager();
803        let mut prog = mgr
804            .compile("test".into(), WAT_CONTINUE.as_bytes(), 0)
805            .unwrap();
806        let ctx = HookContext::Tick;
807        let result = mgr.execute_program(&mut prog, &ctx, &NullEngine, 0.0, None);
808        // Continue → Some with verdict=0
809        let exec = result.unwrap();
810        let r = exec.hook_result.unwrap();
811        assert_eq!(r.verdict, Verdict::Continue as u32);
812    }
813
814    #[test]
815    fn drop_hook() {
816        let mgr = make_manager();
817        let mut prog = mgr
818            .compile("dropper".into(), WAT_DROP.as_bytes(), 0)
819            .unwrap();
820        let ctx = HookContext::Tick;
821        let result = mgr.execute_program(&mut prog, &ctx, &NullEngine, 0.0, None);
822        let exec = result.unwrap();
823        let r = exec.hook_result.unwrap();
824        assert!(r.is_drop());
825    }
826
827    #[test]
828    fn trap_failopen() {
829        let mgr = make_manager();
830        let mut prog = mgr.compile("trap".into(), WAT_TRAP.as_bytes(), 0).unwrap();
831        let ctx = HookContext::Tick;
832        let result = mgr.execute_program(&mut prog, &ctx, &NullEngine, 0.0, None);
833        assert!(result.is_none());
834        assert_eq!(prog.consecutive_traps, 1);
835        assert!(prog.enabled);
836    }
837
838    #[test]
839    fn auto_disable() {
840        let mgr = make_manager();
841        let mut prog = mgr.compile("bad".into(), WAT_TRAP.as_bytes(), 0).unwrap();
842        let ctx = HookContext::Tick;
843        for _ in 0..10 {
844            let _ = mgr.execute_program(&mut prog, &ctx, &NullEngine, 0.0, None);
845        }
846        assert!(!prog.enabled);
847        assert_eq!(prog.consecutive_traps, 10);
848    }
849
850    #[test]
851    fn fuel_exhaustion() {
852        let mgr = make_manager();
853        let mut prog = mgr
854            .compile("loop".into(), WAT_INFINITE.as_bytes(), 0)
855            .unwrap();
856        let ctx = HookContext::Tick;
857        let result = mgr.execute_program(&mut prog, &ctx, &NullEngine, 0.0, None);
858        // Should fail-open (fuel exhausted = trap)
859        assert!(result.is_none());
860        assert_eq!(prog.consecutive_traps, 1);
861    }
862
863    #[test]
864    fn fuel_exhaustion_recovers_cached_store_on_next_call() {
865        let mgr = make_manager();
866        let mut prog = mgr
867            .compile("fuel-recovery".into(), WAT_FUEL_RECOVERY.as_bytes(), 0)
868            .unwrap();
869        let ctx = HookContext::Tick;
870
871        assert!(mgr
872            .execute_program(&mut prog, &ctx, &NullEngine, 0.0, None)
873            .is_none());
874        assert_eq!(prog.consecutive_traps, 1);
875
876        let recovered = mgr
877            .execute_program(&mut prog, &ctx, &NullEngine, 0.0, None)
878            .expect("fuel must be reset before reusing the cached store");
879        assert_eq!(
880            recovered.hook_result.unwrap().verdict,
881            Verdict::Continue as u32
882        );
883        assert_eq!(prog.consecutive_traps, 0);
884    }
885
886    #[test]
887    fn bulk_memory_fill_and_copy_preserve_all_bytes() {
888        let mgr = make_manager();
889        let mut prog = mgr
890            .compile("bulk-memory".into(), WAT_BULK_MEMORY.as_bytes(), 0)
891            .unwrap();
892
893        let result = mgr
894            .execute_program(&mut prog, &HookContext::Tick, &NullEngine, 0.0, None)
895            .unwrap();
896        let copied = result.modified_data.unwrap();
897        assert_eq!(copied.len(), 0x4000);
898        assert!(copied.iter().all(|byte| *byte == 0x5a));
899    }
900
901    #[test]
902    fn chain_ordering() {
903        let mgr = make_manager();
904        let high = mgr
905            .compile("high".into(), WAT_DROP.as_bytes(), 100)
906            .unwrap();
907        let low = mgr
908            .compile("low".into(), WAT_CONTINUE.as_bytes(), 0)
909            .unwrap();
910        // Programs sorted by priority desc: high first
911        let mut programs = vec![high, low];
912        // Sort descending by priority (as attach would do)
913        programs.sort_by_key(|program| std::cmp::Reverse(program.priority));
914
915        let ctx = HookContext::Tick;
916        let result = mgr.run_chain(&mut programs, &ctx, &NullEngine, 0.0);
917        // High priority drops → chain stops
918        let exec = result.unwrap();
919        let r = exec.hook_result.unwrap();
920        assert!(r.is_drop());
921    }
922
923    #[test]
924    fn attach_detach() {
925        use crate::hooks::HookSlot;
926
927        let mgr = make_manager();
928        let mut slot = HookSlot {
929            programs: Vec::new(),
930            runner: crate::hooks::hook_noop,
931        };
932
933        let p1 = mgr
934            .compile("alpha".into(), WAT_CONTINUE.as_bytes(), 10)
935            .unwrap();
936        let p2 = mgr.compile("beta".into(), WAT_DROP.as_bytes(), 20).unwrap();
937
938        slot.attach(p1);
939        assert_eq!(slot.programs.len(), 1);
940        assert!(!std::ptr::eq(
941            slot.runner as *const (),
942            crate::hooks::hook_noop as *const (),
943        ));
944
945        slot.attach(p2);
946        assert_eq!(slot.programs.len(), 2);
947        // Sorted descending: beta(20) before alpha(10)
948        assert_eq!(slot.programs[0].name, "beta");
949        assert_eq!(slot.programs[1].name, "alpha");
950
951        let removed = slot.detach("beta");
952        assert!(removed.is_some());
953        assert_eq!(slot.programs.len(), 1);
954        assert_eq!(slot.programs[0].name, "alpha");
955
956        let removed2 = slot.detach("alpha");
957        assert!(removed2.is_some());
958        assert!(slot.programs.is_empty());
959        assert_eq!(
960            slot.runner as *const () as usize,
961            crate::hooks::hook_noop as *const () as usize
962        );
963    }
964
965    #[test]
966    fn host_has_path() {
967        use crate::engine_access::EngineAccess;
968
969        struct MockEngine;
970        impl EngineAccess for MockEngine {
971            fn has_path(&self, _dest: &[u8; 16]) -> bool {
972                true
973            }
974            fn hops_to(&self, _: &[u8; 16]) -> Option<u8> {
975                None
976            }
977            fn next_hop(&self, _: &[u8; 16]) -> Option<[u8; 16]> {
978                None
979            }
980            fn is_blackholed(&self, _: &[u8; 16]) -> bool {
981                false
982            }
983            fn interface_name(&self, _: u64) -> Option<String> {
984                None
985            }
986            fn interface_mode(&self, _: u64) -> Option<u8> {
987                None
988            }
989            fn identity_hash(&self) -> Option<[u8; 16]> {
990                None
991            }
992            fn announce_rate(&self, _: u64) -> Option<i32> {
993                None
994            }
995            fn link_state(&self, _: &[u8; 16]) -> Option<u8> {
996                None
997            }
998        }
999
1000        let mgr = make_manager();
1001        let mut prog = mgr
1002            .compile("pathcheck".into(), WAT_HOST_HAS_PATH.as_bytes(), 0)
1003            .unwrap();
1004        let ctx = HookContext::Tick;
1005        let result = mgr.execute_program(&mut prog, &ctx, &MockEngine, 0.0, None);
1006        // MockEngine.has_path returns true → WASM drops
1007        let exec = result.unwrap();
1008        let r = exec.hook_result.unwrap();
1009        assert!(r.is_drop());
1010    }
1011
1012    #[test]
1013    fn host_has_path_null_engine() {
1014        // NullEngine.has_path returns false → WASM continues
1015        let mgr = make_manager();
1016        let mut prog = mgr
1017            .compile("pathcheck".into(), WAT_HOST_HAS_PATH.as_bytes(), 0)
1018            .unwrap();
1019        let ctx = HookContext::Tick;
1020        let result = mgr.execute_program(&mut prog, &ctx, &NullEngine, 0.0, None);
1021        let exec = result.unwrap();
1022        let r = exec.hook_result.unwrap();
1023        assert_eq!(r.verdict, Verdict::Continue as u32);
1024    }
1025
1026    // --- New Phase 2 tests ---
1027
1028    /// Configurable mock engine for testing host functions.
1029    struct MockEngineCustom {
1030        announce_rate_val: Option<i32>,
1031        link_state_val: Option<u8>,
1032    }
1033
1034    impl EngineAccess for MockEngineCustom {
1035        fn has_path(&self, _: &[u8; 16]) -> bool {
1036            false
1037        }
1038        fn hops_to(&self, _: &[u8; 16]) -> Option<u8> {
1039            None
1040        }
1041        fn next_hop(&self, _: &[u8; 16]) -> Option<[u8; 16]> {
1042            None
1043        }
1044        fn is_blackholed(&self, _: &[u8; 16]) -> bool {
1045            false
1046        }
1047        fn interface_name(&self, _: u64) -> Option<String> {
1048            None
1049        }
1050        fn interface_mode(&self, _: u64) -> Option<u8> {
1051            None
1052        }
1053        fn identity_hash(&self) -> Option<[u8; 16]> {
1054            None
1055        }
1056        fn announce_rate(&self, _: u64) -> Option<i32> {
1057            self.announce_rate_val
1058        }
1059        fn link_state(&self, _: &[u8; 16]) -> Option<u8> {
1060            self.link_state_val
1061        }
1062    }
1063
1064    /// WAT: calls host_get_announce_rate(42), if result >= 0 → Drop, else Continue.
1065    const WAT_ANNOUNCE_RATE: &str = r#"
1066        (module
1067            (import "env" "host_get_announce_rate" (func $get_rate (param i64) (result i32)))
1068            (memory (export "memory") 1)
1069            (func (export "__rns_abi_version") (result i32) (i32.const 1))
1070            (func (export "on_hook") (param $ctx_ptr i32) (result i32)
1071                (if (i32.ge_s (call $get_rate (i64.const 42)) (i32.const 0))
1072                    (then
1073                        (i32.store (i32.const 0x2000) (i32.const 1)) ;; Drop
1074                    )
1075                    (else
1076                        (i32.store (i32.const 0x2000) (i32.const 0)) ;; Continue
1077                    )
1078                )
1079                (i32.store (i32.add (i32.const 0x2000) (i32.const 4)) (i32.const 0))
1080                (i32.store (i32.add (i32.const 0x2000) (i32.const 8)) (i32.const 0))
1081                (i32.store (i32.add (i32.const 0x2000) (i32.const 12)) (i32.const 0))
1082                (i32.store (i32.add (i32.const 0x2000) (i32.const 16)) (i32.const 0))
1083                (i32.store (i32.add (i32.const 0x2000) (i32.const 20)) (i32.const 0))
1084                (i32.store (i32.add (i32.const 0x2000) (i32.const 24)) (i32.const 0))
1085                (i32.const 0x2000)
1086            )
1087        )
1088    "#;
1089
1090    #[test]
1091    fn host_get_announce_rate_found() {
1092        // announce_rate returns Some(1500) (1.5 Hz * 1000) → Drop
1093        let engine = MockEngineCustom {
1094            announce_rate_val: Some(1500),
1095            link_state_val: None,
1096        };
1097        let mgr = make_manager();
1098        let mut prog = mgr
1099            .compile("rate".into(), WAT_ANNOUNCE_RATE.as_bytes(), 0)
1100            .unwrap();
1101        let ctx = HookContext::Tick;
1102        let exec = mgr
1103            .execute_program(&mut prog, &ctx, &engine, 0.0, None)
1104            .unwrap();
1105        assert!(exec.hook_result.unwrap().is_drop());
1106    }
1107
1108    #[test]
1109    fn host_get_announce_rate_not_found() {
1110        // announce_rate returns None → -1 → Continue
1111        let engine = MockEngineCustom {
1112            announce_rate_val: None,
1113            link_state_val: None,
1114        };
1115        let mgr = make_manager();
1116        let mut prog = mgr
1117            .compile("rate".into(), WAT_ANNOUNCE_RATE.as_bytes(), 0)
1118            .unwrap();
1119        let ctx = HookContext::Tick;
1120        let exec = mgr
1121            .execute_program(&mut prog, &ctx, &engine, 0.0, None)
1122            .unwrap();
1123        assert_eq!(exec.hook_result.unwrap().verdict, Verdict::Continue as u32);
1124    }
1125
1126    /// WAT: calls host_get_link_state with 16-byte hash at 0x3000.
1127    /// If state == 2 (Active) → Drop, else Continue.
1128    const WAT_LINK_STATE: &str = r#"
1129        (module
1130            (import "env" "host_get_link_state" (func $link_state (param i32) (result i32)))
1131            (memory (export "memory") 1)
1132            (func (export "__rns_abi_version") (result i32) (i32.const 1))
1133            (func (export "on_hook") (param $ctx_ptr i32) (result i32)
1134                (if (i32.eq (call $link_state (i32.const 0x3000)) (i32.const 2))
1135                    (then
1136                        (i32.store (i32.const 0x2000) (i32.const 1)) ;; Drop
1137                    )
1138                    (else
1139                        (i32.store (i32.const 0x2000) (i32.const 0)) ;; Continue
1140                    )
1141                )
1142                (i32.store (i32.add (i32.const 0x2000) (i32.const 4)) (i32.const 0))
1143                (i32.store (i32.add (i32.const 0x2000) (i32.const 8)) (i32.const 0))
1144                (i32.store (i32.add (i32.const 0x2000) (i32.const 12)) (i32.const 0))
1145                (i32.store (i32.add (i32.const 0x2000) (i32.const 16)) (i32.const 0))
1146                (i32.store (i32.add (i32.const 0x2000) (i32.const 20)) (i32.const 0))
1147                (i32.store (i32.add (i32.const 0x2000) (i32.const 24)) (i32.const 0))
1148                (i32.const 0x2000)
1149            )
1150        )
1151    "#;
1152
1153    #[test]
1154    fn host_get_link_state_active() {
1155        // link_state returns Some(2) (Active) → Drop
1156        let engine = MockEngineCustom {
1157            announce_rate_val: None,
1158            link_state_val: Some(2),
1159        };
1160        let mgr = make_manager();
1161        let mut prog = mgr
1162            .compile("linkst".into(), WAT_LINK_STATE.as_bytes(), 0)
1163            .unwrap();
1164        let ctx = HookContext::Tick;
1165        let exec = mgr
1166            .execute_program(&mut prog, &ctx, &engine, 0.0, None)
1167            .unwrap();
1168        assert!(exec.hook_result.unwrap().is_drop());
1169    }
1170
1171    #[test]
1172    fn host_get_link_state_not_found() {
1173        // link_state returns None → -1, which != 2 → Continue
1174        let engine = MockEngineCustom {
1175            announce_rate_val: None,
1176            link_state_val: None,
1177        };
1178        let mgr = make_manager();
1179        let mut prog = mgr
1180            .compile("linkst".into(), WAT_LINK_STATE.as_bytes(), 0)
1181            .unwrap();
1182        let ctx = HookContext::Tick;
1183        let exec = mgr
1184            .execute_program(&mut prog, &ctx, &engine, 0.0, None)
1185            .unwrap();
1186        assert_eq!(exec.hook_result.unwrap().verdict, Verdict::Continue as u32);
1187    }
1188
1189    /// WAT: writes a SendOnInterface ActionWire at 0x3000 and calls host_inject_action.
1190    /// Binary: tag=0 (SendOnInterface), interface=1 (u64 LE), data_offset=0x3100, data_len=4
1191    /// Data at 0x3100: [0xDE, 0xAD, 0xBE, 0xEF]
1192    /// Returns Continue.
1193    const WAT_INJECT_ACTION: &str = r#"
1194        (module
1195            (import "env" "host_inject_action" (func $inject (param i32 i32) (result i32)))
1196            (memory (export "memory") 1)
1197            (func (export "__rns_abi_version") (result i32) (i32.const 1))
1198            (func (export "on_hook") (param $ctx_ptr i32) (result i32)
1199                ;; Write the data payload at 0x3100
1200                (i32.store8 (i32.const 0x3100) (i32.const 0xDE))
1201                (i32.store8 (i32.const 0x3101) (i32.const 0xAD))
1202                (i32.store8 (i32.const 0x3102) (i32.const 0xBE))
1203                (i32.store8 (i32.const 0x3103) (i32.const 0xEF))
1204
1205                ;; Write ActionWire at 0x3000:
1206                ;; byte 0: tag = 0 (SendOnInterface)
1207                (i32.store8 (i32.const 0x3000) (i32.const 0))
1208                ;; bytes 1-8: interface = 1 (u64 LE)
1209                (i64.store (i32.const 0x3001) (i64.const 1))
1210                ;; bytes 9-12: data_offset = 0x3100 (u32 LE)
1211                (i32.store (i32.const 0x3009) (i32.const 0x3100))
1212                ;; bytes 13-16: data_len = 4 (u32 LE)
1213                (i32.store (i32.const 0x300D) (i32.const 4))
1214
1215                ;; Call inject: ptr=0x3000, len=17 (1 + 8 + 4 + 4)
1216                (drop (call $inject (i32.const 0x3000) (i32.const 17)))
1217
1218                ;; Return Continue
1219                (i32.store (i32.const 0x2000) (i32.const 0))
1220                (i32.store (i32.add (i32.const 0x2000) (i32.const 4)) (i32.const 0))
1221                (i32.store (i32.add (i32.const 0x2000) (i32.const 8)) (i32.const 0))
1222                (i32.store (i32.add (i32.const 0x2000) (i32.const 12)) (i32.const 0))
1223                (i32.store (i32.add (i32.const 0x2000) (i32.const 16)) (i32.const 0))
1224                (i32.store (i32.add (i32.const 0x2000) (i32.const 20)) (i32.const 0))
1225                (i32.store (i32.add (i32.const 0x2000) (i32.const 24)) (i32.const 0))
1226                (i32.const 0x2000)
1227            )
1228        )
1229    "#;
1230
1231    #[test]
1232    fn host_inject_action_send() {
1233        let mgr = make_manager();
1234        let mut prog = mgr
1235            .compile("inject".into(), WAT_INJECT_ACTION.as_bytes(), 0)
1236            .unwrap();
1237        let ctx = HookContext::Tick;
1238        let exec = mgr
1239            .execute_program(&mut prog, &ctx, &NullEngine, 0.0, None)
1240            .unwrap();
1241        assert_eq!(exec.hook_result.unwrap().verdict, Verdict::Continue as u32);
1242        assert_eq!(exec.injected_actions.len(), 1);
1243        match &exec.injected_actions[0] {
1244            crate::wire::ActionWire::SendOnInterface { interface, raw } => {
1245                assert_eq!(*interface, 1);
1246                assert_eq!(raw, &[0xDE, 0xAD, 0xBE, 0xEF]);
1247            }
1248            other => panic!("expected SendOnInterface, got {:?}", other),
1249        }
1250    }
1251
1252    /// WAT: returns Modify (verdict=2) with modified data at 0x2100 (4 bytes).
1253    const WAT_MODIFY: &str = r#"
1254        (module
1255            (memory (export "memory") 1)
1256            (func (export "__rns_abi_version") (result i32) (i32.const 1))
1257            (func (export "on_hook") (param $ctx_ptr i32) (result i32)
1258                ;; Write modified data at 0x2100
1259                (i32.store8 (i32.const 0x2100) (i32.const 0xAA))
1260                (i32.store8 (i32.const 0x2101) (i32.const 0xBB))
1261                (i32.store8 (i32.const 0x2102) (i32.const 0xCC))
1262                (i32.store8 (i32.const 0x2103) (i32.const 0xDD))
1263
1264                ;; verdict = 2 (Modify)
1265                (i32.store (i32.const 0x2000) (i32.const 2))
1266                ;; modified_data_offset = 0x2100
1267                (i32.store (i32.add (i32.const 0x2000) (i32.const 4)) (i32.const 0x2100))
1268                ;; modified_data_len = 4
1269                (i32.store (i32.add (i32.const 0x2000) (i32.const 8)) (i32.const 4))
1270                (i32.store (i32.add (i32.const 0x2000) (i32.const 12)) (i32.const 0))
1271                (i32.store (i32.add (i32.const 0x2000) (i32.const 16)) (i32.const 0))
1272                (i32.store (i32.add (i32.const 0x2000) (i32.const 20)) (i32.const 0))
1273                (i32.store (i32.add (i32.const 0x2000) (i32.const 24)) (i32.const 0))
1274                (i32.const 0x2000)
1275            )
1276        )
1277    "#;
1278
1279    #[test]
1280    fn modify_extracts_data() {
1281        let mgr = make_manager();
1282        let mut prog = mgr.compile("mod".into(), WAT_MODIFY.as_bytes(), 0).unwrap();
1283        let ctx = HookContext::Tick;
1284        let exec = mgr
1285            .execute_program(&mut prog, &ctx, &NullEngine, 0.0, None)
1286            .unwrap();
1287        let r = exec.hook_result.unwrap();
1288        assert_eq!(r.verdict, Verdict::Modify as u32);
1289        let data = exec.modified_data.unwrap();
1290        assert_eq!(data, vec![0xAA, 0xBB, 0xCC, 0xDD]);
1291    }
1292
1293    #[test]
1294    fn chain_accumulates_injected_actions() {
1295        // Chain: inject_action module (Continue) + Drop module
1296        // Both should contribute to the result; injected actions should be accumulated
1297        let mgr = make_manager();
1298        let injector = mgr
1299            .compile("injector".into(), WAT_INJECT_ACTION.as_bytes(), 100)
1300            .unwrap();
1301        let dropper = mgr
1302            .compile("dropper".into(), WAT_DROP.as_bytes(), 0)
1303            .unwrap();
1304        let mut programs = vec![injector, dropper];
1305        programs.sort_by_key(|program| std::cmp::Reverse(program.priority));
1306
1307        let ctx = HookContext::Tick;
1308        let exec = mgr
1309            .run_chain(&mut programs, &ctx, &NullEngine, 0.0)
1310            .unwrap();
1311        // Chain should drop (second hook)
1312        assert!(exec.hook_result.unwrap().is_drop());
1313        // But injected action from first hook should be present
1314        assert_eq!(exec.injected_actions.len(), 1);
1315    }
1316
1317    // --- Instance persistence tests ---
1318
1319    /// WAT module with a mutable global counter. Each call increments it and
1320    /// writes the counter value into the verdict field (abusing it as an integer).
1321    /// This lets us verify that the global persists across calls.
1322    const WAT_COUNTER: &str = r#"
1323        (module
1324            (memory (export "memory") 1)
1325            (func (export "__rns_abi_version") (result i32) (i32.const 1))
1326            (global $counter (mut i32) (i32.const 0))
1327            (func (export "on_hook") (param i32) (result i32)
1328                ;; Increment counter
1329                (global.set $counter (i32.add (global.get $counter) (i32.const 1)))
1330                ;; Write counter value at 0x3000 (scratch area)
1331                (i32.store (i32.const 0x3000) (global.get $counter))
1332                ;; Return Continue with the counter stashed in modified_data region
1333                ;; verdict = 2 (Modify) so we can extract the counter via modified_data
1334                (i32.store (i32.const 0x2000) (i32.const 2))
1335                ;; modified_data_offset = 0x3000
1336                (i32.store (i32.add (i32.const 0x2000) (i32.const 4)) (i32.const 0x3000))
1337                ;; modified_data_len = 4
1338                (i32.store (i32.add (i32.const 0x2000) (i32.const 8)) (i32.const 4))
1339                (i32.store (i32.add (i32.const 0x2000) (i32.const 12)) (i32.const 0))
1340                (i32.store (i32.add (i32.const 0x2000) (i32.const 16)) (i32.const 0))
1341                (i32.store (i32.add (i32.const 0x2000) (i32.const 20)) (i32.const 0))
1342                (i32.store (i32.add (i32.const 0x2000) (i32.const 24)) (i32.const 0))
1343                (i32.const 0x2000)
1344            )
1345        )
1346    "#;
1347
1348    fn extract_counter(exec: &ExecuteResult) -> u32 {
1349        let data = exec.modified_data.as_ref().expect("no modified data");
1350        assert_eq!(data.len(), 4);
1351        u32::from_le_bytes([data[0], data[1], data[2], data[3]])
1352    }
1353
1354    #[test]
1355    fn instance_persistence_counter() {
1356        let mgr = make_manager();
1357        let mut prog = mgr
1358            .compile("counter".into(), WAT_COUNTER.as_bytes(), 0)
1359            .unwrap();
1360        let ctx = HookContext::Tick;
1361
1362        // Call 3 times — counter should increment across calls
1363        let exec1 = mgr
1364            .execute_program(&mut prog, &ctx, &NullEngine, 0.0, None)
1365            .unwrap();
1366        assert_eq!(extract_counter(&exec1), 1);
1367
1368        let exec2 = mgr
1369            .execute_program(&mut prog, &ctx, &NullEngine, 0.0, None)
1370            .unwrap();
1371        assert_eq!(extract_counter(&exec2), 2);
1372
1373        let exec3 = mgr
1374            .execute_program(&mut prog, &ctx, &NullEngine, 0.0, None)
1375            .unwrap();
1376        assert_eq!(extract_counter(&exec3), 3);
1377    }
1378
1379    #[test]
1380    fn instance_persistence_resets_on_drop_cache() {
1381        let mgr = make_manager();
1382        let mut prog = mgr
1383            .compile("counter".into(), WAT_COUNTER.as_bytes(), 0)
1384            .unwrap();
1385        let ctx = HookContext::Tick;
1386
1387        // Increment twice
1388        mgr.execute_program(&mut prog, &ctx, &NullEngine, 0.0, None)
1389            .unwrap();
1390        let exec2 = mgr
1391            .execute_program(&mut prog, &ctx, &NullEngine, 0.0, None)
1392            .unwrap();
1393        assert_eq!(extract_counter(&exec2), 2);
1394
1395        // Drop cache (simulates reload)
1396        prog.drop_cache();
1397
1398        // Counter should restart at 1
1399        let exec3 = mgr
1400            .execute_program(&mut prog, &ctx, &NullEngine, 0.0, None)
1401            .unwrap();
1402        assert_eq!(extract_counter(&exec3), 1);
1403    }
1404
1405    // --- ABI version validation tests ---
1406
1407    /// WAT module without __rns_abi_version export.
1408    const WAT_NO_ABI_VERSION: &str = r#"
1409        (module
1410            (memory (export "memory") 1)
1411            (func (export "on_hook") (param i32) (result i32)
1412                (i32.store (i32.const 0x2000) (i32.const 0))
1413                (i32.store (i32.add (i32.const 0x2000) (i32.const 4)) (i32.const 0))
1414                (i32.store (i32.add (i32.const 0x2000) (i32.const 8)) (i32.const 0))
1415                (i32.store (i32.add (i32.const 0x2000) (i32.const 12)) (i32.const 0))
1416                (i32.store (i32.add (i32.const 0x2000) (i32.const 16)) (i32.const 0))
1417                (i32.store (i32.add (i32.const 0x2000) (i32.const 20)) (i32.const 0))
1418                (i32.store (i32.add (i32.const 0x2000) (i32.const 24)) (i32.const 0))
1419                (i32.const 0x2000)
1420            )
1421        )
1422    "#;
1423
1424    /// WAT module with wrong ABI version (9999).
1425    const WAT_WRONG_ABI_VERSION: &str = r#"
1426        (module
1427            (memory (export "memory") 1)
1428            (func (export "__rns_abi_version") (result i32) (i32.const 9999))
1429            (func (export "on_hook") (param i32) (result i32)
1430                (i32.store (i32.const 0x2000) (i32.const 0))
1431                (i32.store (i32.add (i32.const 0x2000) (i32.const 4)) (i32.const 0))
1432                (i32.store (i32.add (i32.const 0x2000) (i32.const 8)) (i32.const 0))
1433                (i32.store (i32.add (i32.const 0x2000) (i32.const 12)) (i32.const 0))
1434                (i32.store (i32.add (i32.const 0x2000) (i32.const 16)) (i32.const 0))
1435                (i32.store (i32.add (i32.const 0x2000) (i32.const 20)) (i32.const 0))
1436                (i32.store (i32.add (i32.const 0x2000) (i32.const 24)) (i32.const 0))
1437                (i32.const 0x2000)
1438            )
1439        )
1440    "#;
1441
1442    #[test]
1443    fn rejects_missing_abi_version() {
1444        let mgr = make_manager();
1445        let result = mgr.compile("no_abi".into(), WAT_NO_ABI_VERSION.as_bytes(), 0);
1446        match result {
1447            Err(HookError::AbiVersionMismatch {
1448                hook_name,
1449                expected,
1450                found,
1451            }) => {
1452                assert_eq!(hook_name, "no_abi");
1453                assert_eq!(expected, HOST_ABI_VERSION);
1454                assert_eq!(found, None);
1455            }
1456            other => panic!(
1457                "expected AbiVersionMismatch with found=None, got {:?}",
1458                other.err()
1459            ),
1460        }
1461    }
1462
1463    #[test]
1464    fn rejects_wrong_abi_version() {
1465        let mgr = make_manager();
1466        let result = mgr.compile("bad_abi".into(), WAT_WRONG_ABI_VERSION.as_bytes(), 0);
1467        match result {
1468            Err(HookError::AbiVersionMismatch {
1469                hook_name,
1470                expected,
1471                found,
1472            }) => {
1473                assert_eq!(hook_name, "bad_abi");
1474                assert_eq!(expected, HOST_ABI_VERSION);
1475                assert_eq!(found, Some(9999));
1476            }
1477            other => panic!(
1478                "expected AbiVersionMismatch with found=Some(9999), got {:?}",
1479                other.err()
1480            ),
1481        }
1482    }
1483
1484    #[test]
1485    fn accepts_correct_abi_version() {
1486        let mgr = make_manager();
1487        let result = mgr.compile("good_abi".into(), WAT_CONTINUE.as_bytes(), 0);
1488        assert!(
1489            result.is_ok(),
1490            "compile should succeed with correct ABI version"
1491        );
1492    }
1493
1494    #[test]
1495    fn host_emit_event_collects_provider_event() {
1496        let mgr = make_manager();
1497        let wat = r#"
1498            (module
1499                (import "env" "host_emit_event" (func $emit (param i32 i32 i32 i32) (result i32)))
1500                (memory (export "memory") 1)
1501                (data (i32.const 0x3000) "packet")
1502                (data (i32.const 0x3010) "\01\02\03")
1503                (func (export "__rns_abi_version") (result i32) (i32.const 1))
1504                (func (export "on_hook") (param i32) (result i32)
1505                    (drop (call $emit (i32.const 0x3000) (i32.const 6) (i32.const 0x3010) (i32.const 3)))
1506                    (i32.store (i32.const 0x2000) (i32.const 0))
1507                    (i32.store (i32.add (i32.const 0x2000) (i32.const 4)) (i32.const 0))
1508                    (i32.store (i32.add (i32.const 0x2000) (i32.const 8)) (i32.const 0))
1509                    (i32.store (i32.add (i32.const 0x2000) (i32.const 12)) (i32.const 0))
1510                    (i32.store (i32.add (i32.const 0x2000) (i32.const 16)) (i32.const 0))
1511                    (i32.store (i32.add (i32.const 0x2000) (i32.const 20)) (i32.const 0))
1512                    (i32.store (i32.add (i32.const 0x2000) (i32.const 24)) (i32.const 0))
1513                    (i32.const 0x2000)
1514                )
1515            )
1516        "#;
1517        let module = mgr.runtime.compile(&wat::parse_str(wat).unwrap()).unwrap();
1518        let mut prog = LoadedProgram::new("emit".into(), module, 0);
1519        let ctx = HookContext::Tick;
1520
1521        let exec = mgr
1522            .execute_program_with_provider_events(&mut prog, &ctx, &NullEngine, 0.0, true, None)
1523            .unwrap();
1524        assert_eq!(exec.provider_events.len(), 1);
1525        assert_eq!(exec.provider_events[0].hook_name, "emit");
1526        assert_eq!(exec.provider_events[0].payload_type, "packet");
1527        assert_eq!(exec.provider_events[0].payload, vec![1, 2, 3]);
1528    }
1529
1530    #[test]
1531    fn host_emit_event_is_ignored_when_disabled() {
1532        let mgr = make_manager();
1533        let wat = r#"
1534            (module
1535                (import "env" "host_emit_event" (func $emit (param i32 i32 i32 i32) (result i32)))
1536                (memory (export "memory") 1)
1537                (data (i32.const 0x3000) "packet")
1538                (data (i32.const 0x3010) "\01\02\03")
1539                (func (export "__rns_abi_version") (result i32) (i32.const 1))
1540                (func (export "on_hook") (param i32) (result i32)
1541                    (drop (call $emit (i32.const 0x3000) (i32.const 6) (i32.const 0x3010) (i32.const 3)))
1542                    (i32.store (i32.const 0x2000) (i32.const 0))
1543                    (i32.store (i32.add (i32.const 0x2000) (i32.const 4)) (i32.const 0))
1544                    (i32.store (i32.add (i32.const 0x2000) (i32.const 8)) (i32.const 0))
1545                    (i32.store (i32.add (i32.const 0x2000) (i32.const 12)) (i32.const 0))
1546                    (i32.store (i32.add (i32.const 0x2000) (i32.const 16)) (i32.const 0))
1547                    (i32.store (i32.add (i32.const 0x2000) (i32.const 20)) (i32.const 0))
1548                    (i32.store (i32.add (i32.const 0x2000) (i32.const 24)) (i32.const 0))
1549                    (i32.const 0x2000)
1550                )
1551            )
1552        "#;
1553        let module = mgr.runtime.compile(&wat::parse_str(wat).unwrap()).unwrap();
1554        let mut prog = LoadedProgram::new("emit".into(), module, 0);
1555        let ctx = HookContext::Tick;
1556
1557        let exec = mgr
1558            .execute_program_with_provider_events(&mut prog, &ctx, &NullEngine, 0.0, false, None)
1559            .unwrap();
1560        assert!(exec.provider_events.is_empty());
1561    }
1562
1563    #[test]
1564    fn run_chain_returns_continue_only_provider_events() {
1565        let manager = make_manager();
1566        let wasm = wat::parse_str(
1567            r#"(module
1568                (import "env" "host_emit_event" (func $emit (param i32 i32 i32 i32) (result i32)))
1569                (memory (export "memory") 1)
1570                (data (i32.const 4096) "\00\00\00\00")
1571                (data (i32.const 8192) "packet")
1572                (data (i32.const 8208) "\01\02\03")
1573                (func (export "__rns_abi_version") (result i32) i32.const 1)
1574                (func (export "on_hook") (param i32) (result i32)
1575                    i32.const 8192
1576                    i32.const 6
1577                    i32.const 8208
1578                    i32.const 3
1579                    call $emit
1580                    drop
1581                    i32.const 4096
1582                )
1583            )"#,
1584        )
1585        .unwrap();
1586
1587        let mut programs = vec![manager.compile("emit".into(), &wasm, 0).unwrap()];
1588        let pkt_ctx = crate::PacketContext {
1589            flags: 0,
1590            hops: 0,
1591            destination_hash: [0; 16],
1592            context: 0,
1593            packet_hash: [0; 32],
1594            interface_id: 1,
1595            data_offset: 0,
1596            data_len: 0,
1597        };
1598        let ctx = crate::HookContext::Packet {
1599            ctx: &pkt_ctx,
1600            raw: &[],
1601        };
1602
1603        let exec = manager
1604            .run_chain_with_provider_events(&mut programs, &ctx, &NullEngine, 0.0, true)
1605            .expect("expected provider event result");
1606        assert!(exec.hook_result.is_none());
1607        assert!(exec.injected_actions.is_empty());
1608        assert_eq!(exec.provider_events.len(), 1);
1609        assert_eq!(exec.provider_events[0].payload_type, "packet");
1610        assert_eq!(exec.provider_events[0].payload, vec![1, 2, 3]);
1611    }
1612}