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    /// WAT module that calls host_has_path and drops if path exists.
729    const WAT_HOST_HAS_PATH: &str = r#"
730        (module
731            (import "env" "host_has_path" (func $has_path (param i32) (result i32)))
732            (memory (export "memory") 1)
733            (func (export "__rns_abi_version") (result i32) (i32.const 1))
734            (func (export "on_hook") (param $ctx_ptr i32) (result i32)
735                ;; Check if path exists for a 16-byte dest at offset 0x3000
736                ;; (we'll write the dest hash there in the test)
737                (if (call $has_path (i32.const 0x3000))
738                    (then
739                        ;; Drop
740                        (i32.store (i32.const 0x2000) (i32.const 1))
741                    )
742                    (else
743                        ;; Continue
744                        (i32.store (i32.const 0x2000) (i32.const 0))
745                    )
746                )
747                (i32.store (i32.add (i32.const 0x2000) (i32.const 4)) (i32.const 0))
748                (i32.store (i32.add (i32.const 0x2000) (i32.const 8)) (i32.const 0))
749                (i32.store (i32.add (i32.const 0x2000) (i32.const 12)) (i32.const 0))
750                (i32.store (i32.add (i32.const 0x2000) (i32.const 16)) (i32.const 0))
751                (i32.store (i32.add (i32.const 0x2000) (i32.const 20)) (i32.const 0))
752                (i32.store (i32.add (i32.const 0x2000) (i32.const 24)) (i32.const 0))
753                (i32.const 0x2000)
754            )
755        )
756    "#;
757
758    #[test]
759    fn pass_through() {
760        let mgr = make_manager();
761        let mut prog = mgr
762            .compile("test".into(), WAT_CONTINUE.as_bytes(), 0)
763            .unwrap();
764        let ctx = HookContext::Tick;
765        let result = mgr.execute_program(&mut prog, &ctx, &NullEngine, 0.0, None);
766        // Continue → Some with verdict=0
767        let exec = result.unwrap();
768        let r = exec.hook_result.unwrap();
769        assert_eq!(r.verdict, Verdict::Continue as u32);
770    }
771
772    #[test]
773    fn drop_hook() {
774        let mgr = make_manager();
775        let mut prog = mgr
776            .compile("dropper".into(), WAT_DROP.as_bytes(), 0)
777            .unwrap();
778        let ctx = HookContext::Tick;
779        let result = mgr.execute_program(&mut prog, &ctx, &NullEngine, 0.0, None);
780        let exec = result.unwrap();
781        let r = exec.hook_result.unwrap();
782        assert!(r.is_drop());
783    }
784
785    #[test]
786    fn trap_failopen() {
787        let mgr = make_manager();
788        let mut prog = mgr.compile("trap".into(), WAT_TRAP.as_bytes(), 0).unwrap();
789        let ctx = HookContext::Tick;
790        let result = mgr.execute_program(&mut prog, &ctx, &NullEngine, 0.0, None);
791        assert!(result.is_none());
792        assert_eq!(prog.consecutive_traps, 1);
793        assert!(prog.enabled);
794    }
795
796    #[test]
797    fn auto_disable() {
798        let mgr = make_manager();
799        let mut prog = mgr.compile("bad".into(), WAT_TRAP.as_bytes(), 0).unwrap();
800        let ctx = HookContext::Tick;
801        for _ in 0..10 {
802            let _ = mgr.execute_program(&mut prog, &ctx, &NullEngine, 0.0, None);
803        }
804        assert!(!prog.enabled);
805        assert_eq!(prog.consecutive_traps, 10);
806    }
807
808    #[test]
809    fn fuel_exhaustion() {
810        let mgr = make_manager();
811        let mut prog = mgr
812            .compile("loop".into(), WAT_INFINITE.as_bytes(), 0)
813            .unwrap();
814        let ctx = HookContext::Tick;
815        let result = mgr.execute_program(&mut prog, &ctx, &NullEngine, 0.0, None);
816        // Should fail-open (fuel exhausted = trap)
817        assert!(result.is_none());
818        assert_eq!(prog.consecutive_traps, 1);
819    }
820
821    #[test]
822    fn chain_ordering() {
823        let mgr = make_manager();
824        let high = mgr
825            .compile("high".into(), WAT_DROP.as_bytes(), 100)
826            .unwrap();
827        let low = mgr
828            .compile("low".into(), WAT_CONTINUE.as_bytes(), 0)
829            .unwrap();
830        // Programs sorted by priority desc: high first
831        let mut programs = vec![high, low];
832        // Sort descending by priority (as attach would do)
833        programs.sort_by(|a, b| b.priority.cmp(&a.priority));
834
835        let ctx = HookContext::Tick;
836        let result = mgr.run_chain(&mut programs, &ctx, &NullEngine, 0.0);
837        // High priority drops → chain stops
838        let exec = result.unwrap();
839        let r = exec.hook_result.unwrap();
840        assert!(r.is_drop());
841    }
842
843    #[test]
844    fn attach_detach() {
845        use crate::hooks::HookSlot;
846
847        let mgr = make_manager();
848        let mut slot = HookSlot {
849            programs: Vec::new(),
850            runner: crate::hooks::hook_noop,
851        };
852
853        let p1 = mgr
854            .compile("alpha".into(), WAT_CONTINUE.as_bytes(), 10)
855            .unwrap();
856        let p2 = mgr.compile("beta".into(), WAT_DROP.as_bytes(), 20).unwrap();
857
858        slot.attach(p1);
859        assert_eq!(slot.programs.len(), 1);
860        assert!(!std::ptr::eq(
861            slot.runner as *const (),
862            crate::hooks::hook_noop as *const (),
863        ));
864
865        slot.attach(p2);
866        assert_eq!(slot.programs.len(), 2);
867        // Sorted descending: beta(20) before alpha(10)
868        assert_eq!(slot.programs[0].name, "beta");
869        assert_eq!(slot.programs[1].name, "alpha");
870
871        let removed = slot.detach("beta");
872        assert!(removed.is_some());
873        assert_eq!(slot.programs.len(), 1);
874        assert_eq!(slot.programs[0].name, "alpha");
875
876        let removed2 = slot.detach("alpha");
877        assert!(removed2.is_some());
878        assert!(slot.programs.is_empty());
879        assert_eq!(
880            slot.runner as *const () as usize,
881            crate::hooks::hook_noop as *const () as usize
882        );
883    }
884
885    #[test]
886    fn host_has_path() {
887        use crate::engine_access::EngineAccess;
888
889        struct MockEngine;
890        impl EngineAccess for MockEngine {
891            fn has_path(&self, _dest: &[u8; 16]) -> bool {
892                true
893            }
894            fn hops_to(&self, _: &[u8; 16]) -> Option<u8> {
895                None
896            }
897            fn next_hop(&self, _: &[u8; 16]) -> Option<[u8; 16]> {
898                None
899            }
900            fn is_blackholed(&self, _: &[u8; 16]) -> bool {
901                false
902            }
903            fn interface_name(&self, _: u64) -> Option<String> {
904                None
905            }
906            fn interface_mode(&self, _: u64) -> Option<u8> {
907                None
908            }
909            fn identity_hash(&self) -> Option<[u8; 16]> {
910                None
911            }
912            fn announce_rate(&self, _: u64) -> Option<i32> {
913                None
914            }
915            fn link_state(&self, _: &[u8; 16]) -> Option<u8> {
916                None
917            }
918        }
919
920        let mgr = make_manager();
921        let mut prog = mgr
922            .compile("pathcheck".into(), WAT_HOST_HAS_PATH.as_bytes(), 0)
923            .unwrap();
924        let ctx = HookContext::Tick;
925        let result = mgr.execute_program(&mut prog, &ctx, &MockEngine, 0.0, None);
926        // MockEngine.has_path returns true → WASM drops
927        let exec = result.unwrap();
928        let r = exec.hook_result.unwrap();
929        assert!(r.is_drop());
930    }
931
932    #[test]
933    fn host_has_path_null_engine() {
934        // NullEngine.has_path returns false → WASM continues
935        let mgr = make_manager();
936        let mut prog = mgr
937            .compile("pathcheck".into(), WAT_HOST_HAS_PATH.as_bytes(), 0)
938            .unwrap();
939        let ctx = HookContext::Tick;
940        let result = mgr.execute_program(&mut prog, &ctx, &NullEngine, 0.0, None);
941        let exec = result.unwrap();
942        let r = exec.hook_result.unwrap();
943        assert_eq!(r.verdict, Verdict::Continue as u32);
944    }
945
946    // --- New Phase 2 tests ---
947
948    /// Configurable mock engine for testing host functions.
949    struct MockEngineCustom {
950        announce_rate_val: Option<i32>,
951        link_state_val: Option<u8>,
952    }
953
954    impl EngineAccess for MockEngineCustom {
955        fn has_path(&self, _: &[u8; 16]) -> bool {
956            false
957        }
958        fn hops_to(&self, _: &[u8; 16]) -> Option<u8> {
959            None
960        }
961        fn next_hop(&self, _: &[u8; 16]) -> Option<[u8; 16]> {
962            None
963        }
964        fn is_blackholed(&self, _: &[u8; 16]) -> bool {
965            false
966        }
967        fn interface_name(&self, _: u64) -> Option<String> {
968            None
969        }
970        fn interface_mode(&self, _: u64) -> Option<u8> {
971            None
972        }
973        fn identity_hash(&self) -> Option<[u8; 16]> {
974            None
975        }
976        fn announce_rate(&self, _: u64) -> Option<i32> {
977            self.announce_rate_val
978        }
979        fn link_state(&self, _: &[u8; 16]) -> Option<u8> {
980            self.link_state_val
981        }
982    }
983
984    /// WAT: calls host_get_announce_rate(42), if result >= 0 → Drop, else Continue.
985    const WAT_ANNOUNCE_RATE: &str = r#"
986        (module
987            (import "env" "host_get_announce_rate" (func $get_rate (param i64) (result i32)))
988            (memory (export "memory") 1)
989            (func (export "__rns_abi_version") (result i32) (i32.const 1))
990            (func (export "on_hook") (param $ctx_ptr i32) (result i32)
991                (if (i32.ge_s (call $get_rate (i64.const 42)) (i32.const 0))
992                    (then
993                        (i32.store (i32.const 0x2000) (i32.const 1)) ;; Drop
994                    )
995                    (else
996                        (i32.store (i32.const 0x2000) (i32.const 0)) ;; Continue
997                    )
998                )
999                (i32.store (i32.add (i32.const 0x2000) (i32.const 4)) (i32.const 0))
1000                (i32.store (i32.add (i32.const 0x2000) (i32.const 8)) (i32.const 0))
1001                (i32.store (i32.add (i32.const 0x2000) (i32.const 12)) (i32.const 0))
1002                (i32.store (i32.add (i32.const 0x2000) (i32.const 16)) (i32.const 0))
1003                (i32.store (i32.add (i32.const 0x2000) (i32.const 20)) (i32.const 0))
1004                (i32.store (i32.add (i32.const 0x2000) (i32.const 24)) (i32.const 0))
1005                (i32.const 0x2000)
1006            )
1007        )
1008    "#;
1009
1010    #[test]
1011    fn host_get_announce_rate_found() {
1012        // announce_rate returns Some(1500) (1.5 Hz * 1000) → Drop
1013        let engine = MockEngineCustom {
1014            announce_rate_val: Some(1500),
1015            link_state_val: None,
1016        };
1017        let mgr = make_manager();
1018        let mut prog = mgr
1019            .compile("rate".into(), WAT_ANNOUNCE_RATE.as_bytes(), 0)
1020            .unwrap();
1021        let ctx = HookContext::Tick;
1022        let exec = mgr
1023            .execute_program(&mut prog, &ctx, &engine, 0.0, None)
1024            .unwrap();
1025        assert!(exec.hook_result.unwrap().is_drop());
1026    }
1027
1028    #[test]
1029    fn host_get_announce_rate_not_found() {
1030        // announce_rate returns None → -1 → Continue
1031        let engine = MockEngineCustom {
1032            announce_rate_val: None,
1033            link_state_val: None,
1034        };
1035        let mgr = make_manager();
1036        let mut prog = mgr
1037            .compile("rate".into(), WAT_ANNOUNCE_RATE.as_bytes(), 0)
1038            .unwrap();
1039        let ctx = HookContext::Tick;
1040        let exec = mgr
1041            .execute_program(&mut prog, &ctx, &engine, 0.0, None)
1042            .unwrap();
1043        assert_eq!(exec.hook_result.unwrap().verdict, Verdict::Continue as u32);
1044    }
1045
1046    /// WAT: calls host_get_link_state with 16-byte hash at 0x3000.
1047    /// If state == 2 (Active) → Drop, else Continue.
1048    const WAT_LINK_STATE: &str = r#"
1049        (module
1050            (import "env" "host_get_link_state" (func $link_state (param i32) (result i32)))
1051            (memory (export "memory") 1)
1052            (func (export "__rns_abi_version") (result i32) (i32.const 1))
1053            (func (export "on_hook") (param $ctx_ptr i32) (result i32)
1054                (if (i32.eq (call $link_state (i32.const 0x3000)) (i32.const 2))
1055                    (then
1056                        (i32.store (i32.const 0x2000) (i32.const 1)) ;; Drop
1057                    )
1058                    (else
1059                        (i32.store (i32.const 0x2000) (i32.const 0)) ;; Continue
1060                    )
1061                )
1062                (i32.store (i32.add (i32.const 0x2000) (i32.const 4)) (i32.const 0))
1063                (i32.store (i32.add (i32.const 0x2000) (i32.const 8)) (i32.const 0))
1064                (i32.store (i32.add (i32.const 0x2000) (i32.const 12)) (i32.const 0))
1065                (i32.store (i32.add (i32.const 0x2000) (i32.const 16)) (i32.const 0))
1066                (i32.store (i32.add (i32.const 0x2000) (i32.const 20)) (i32.const 0))
1067                (i32.store (i32.add (i32.const 0x2000) (i32.const 24)) (i32.const 0))
1068                (i32.const 0x2000)
1069            )
1070        )
1071    "#;
1072
1073    #[test]
1074    fn host_get_link_state_active() {
1075        // link_state returns Some(2) (Active) → Drop
1076        let engine = MockEngineCustom {
1077            announce_rate_val: None,
1078            link_state_val: Some(2),
1079        };
1080        let mgr = make_manager();
1081        let mut prog = mgr
1082            .compile("linkst".into(), WAT_LINK_STATE.as_bytes(), 0)
1083            .unwrap();
1084        let ctx = HookContext::Tick;
1085        let exec = mgr
1086            .execute_program(&mut prog, &ctx, &engine, 0.0, None)
1087            .unwrap();
1088        assert!(exec.hook_result.unwrap().is_drop());
1089    }
1090
1091    #[test]
1092    fn host_get_link_state_not_found() {
1093        // link_state returns None → -1, which != 2 → Continue
1094        let engine = MockEngineCustom {
1095            announce_rate_val: None,
1096            link_state_val: None,
1097        };
1098        let mgr = make_manager();
1099        let mut prog = mgr
1100            .compile("linkst".into(), WAT_LINK_STATE.as_bytes(), 0)
1101            .unwrap();
1102        let ctx = HookContext::Tick;
1103        let exec = mgr
1104            .execute_program(&mut prog, &ctx, &engine, 0.0, None)
1105            .unwrap();
1106        assert_eq!(exec.hook_result.unwrap().verdict, Verdict::Continue as u32);
1107    }
1108
1109    /// WAT: writes a SendOnInterface ActionWire at 0x3000 and calls host_inject_action.
1110    /// Binary: tag=0 (SendOnInterface), interface=1 (u64 LE), data_offset=0x3100, data_len=4
1111    /// Data at 0x3100: [0xDE, 0xAD, 0xBE, 0xEF]
1112    /// Returns Continue.
1113    const WAT_INJECT_ACTION: &str = r#"
1114        (module
1115            (import "env" "host_inject_action" (func $inject (param i32 i32) (result i32)))
1116            (memory (export "memory") 1)
1117            (func (export "__rns_abi_version") (result i32) (i32.const 1))
1118            (func (export "on_hook") (param $ctx_ptr i32) (result i32)
1119                ;; Write the data payload at 0x3100
1120                (i32.store8 (i32.const 0x3100) (i32.const 0xDE))
1121                (i32.store8 (i32.const 0x3101) (i32.const 0xAD))
1122                (i32.store8 (i32.const 0x3102) (i32.const 0xBE))
1123                (i32.store8 (i32.const 0x3103) (i32.const 0xEF))
1124
1125                ;; Write ActionWire at 0x3000:
1126                ;; byte 0: tag = 0 (SendOnInterface)
1127                (i32.store8 (i32.const 0x3000) (i32.const 0))
1128                ;; bytes 1-8: interface = 1 (u64 LE)
1129                (i64.store (i32.const 0x3001) (i64.const 1))
1130                ;; bytes 9-12: data_offset = 0x3100 (u32 LE)
1131                (i32.store (i32.const 0x3009) (i32.const 0x3100))
1132                ;; bytes 13-16: data_len = 4 (u32 LE)
1133                (i32.store (i32.const 0x300D) (i32.const 4))
1134
1135                ;; Call inject: ptr=0x3000, len=17 (1 + 8 + 4 + 4)
1136                (drop (call $inject (i32.const 0x3000) (i32.const 17)))
1137
1138                ;; Return Continue
1139                (i32.store (i32.const 0x2000) (i32.const 0))
1140                (i32.store (i32.add (i32.const 0x2000) (i32.const 4)) (i32.const 0))
1141                (i32.store (i32.add (i32.const 0x2000) (i32.const 8)) (i32.const 0))
1142                (i32.store (i32.add (i32.const 0x2000) (i32.const 12)) (i32.const 0))
1143                (i32.store (i32.add (i32.const 0x2000) (i32.const 16)) (i32.const 0))
1144                (i32.store (i32.add (i32.const 0x2000) (i32.const 20)) (i32.const 0))
1145                (i32.store (i32.add (i32.const 0x2000) (i32.const 24)) (i32.const 0))
1146                (i32.const 0x2000)
1147            )
1148        )
1149    "#;
1150
1151    #[test]
1152    fn host_inject_action_send() {
1153        let mgr = make_manager();
1154        let mut prog = mgr
1155            .compile("inject".into(), WAT_INJECT_ACTION.as_bytes(), 0)
1156            .unwrap();
1157        let ctx = HookContext::Tick;
1158        let exec = mgr
1159            .execute_program(&mut prog, &ctx, &NullEngine, 0.0, None)
1160            .unwrap();
1161        assert_eq!(exec.hook_result.unwrap().verdict, Verdict::Continue as u32);
1162        assert_eq!(exec.injected_actions.len(), 1);
1163        match &exec.injected_actions[0] {
1164            crate::wire::ActionWire::SendOnInterface { interface, raw } => {
1165                assert_eq!(*interface, 1);
1166                assert_eq!(raw, &[0xDE, 0xAD, 0xBE, 0xEF]);
1167            }
1168            other => panic!("expected SendOnInterface, got {:?}", other),
1169        }
1170    }
1171
1172    /// WAT: returns Modify (verdict=2) with modified data at 0x2100 (4 bytes).
1173    const WAT_MODIFY: &str = r#"
1174        (module
1175            (memory (export "memory") 1)
1176            (func (export "__rns_abi_version") (result i32) (i32.const 1))
1177            (func (export "on_hook") (param $ctx_ptr i32) (result i32)
1178                ;; Write modified data at 0x2100
1179                (i32.store8 (i32.const 0x2100) (i32.const 0xAA))
1180                (i32.store8 (i32.const 0x2101) (i32.const 0xBB))
1181                (i32.store8 (i32.const 0x2102) (i32.const 0xCC))
1182                (i32.store8 (i32.const 0x2103) (i32.const 0xDD))
1183
1184                ;; verdict = 2 (Modify)
1185                (i32.store (i32.const 0x2000) (i32.const 2))
1186                ;; modified_data_offset = 0x2100
1187                (i32.store (i32.add (i32.const 0x2000) (i32.const 4)) (i32.const 0x2100))
1188                ;; modified_data_len = 4
1189                (i32.store (i32.add (i32.const 0x2000) (i32.const 8)) (i32.const 4))
1190                (i32.store (i32.add (i32.const 0x2000) (i32.const 12)) (i32.const 0))
1191                (i32.store (i32.add (i32.const 0x2000) (i32.const 16)) (i32.const 0))
1192                (i32.store (i32.add (i32.const 0x2000) (i32.const 20)) (i32.const 0))
1193                (i32.store (i32.add (i32.const 0x2000) (i32.const 24)) (i32.const 0))
1194                (i32.const 0x2000)
1195            )
1196        )
1197    "#;
1198
1199    #[test]
1200    fn modify_extracts_data() {
1201        let mgr = make_manager();
1202        let mut prog = mgr.compile("mod".into(), WAT_MODIFY.as_bytes(), 0).unwrap();
1203        let ctx = HookContext::Tick;
1204        let exec = mgr
1205            .execute_program(&mut prog, &ctx, &NullEngine, 0.0, None)
1206            .unwrap();
1207        let r = exec.hook_result.unwrap();
1208        assert_eq!(r.verdict, Verdict::Modify as u32);
1209        let data = exec.modified_data.unwrap();
1210        assert_eq!(data, vec![0xAA, 0xBB, 0xCC, 0xDD]);
1211    }
1212
1213    #[test]
1214    fn chain_accumulates_injected_actions() {
1215        // Chain: inject_action module (Continue) + Drop module
1216        // Both should contribute to the result; injected actions should be accumulated
1217        let mgr = make_manager();
1218        let injector = mgr
1219            .compile("injector".into(), WAT_INJECT_ACTION.as_bytes(), 100)
1220            .unwrap();
1221        let dropper = mgr
1222            .compile("dropper".into(), WAT_DROP.as_bytes(), 0)
1223            .unwrap();
1224        let mut programs = vec![injector, dropper];
1225        programs.sort_by(|a, b| b.priority.cmp(&a.priority));
1226
1227        let ctx = HookContext::Tick;
1228        let exec = mgr
1229            .run_chain(&mut programs, &ctx, &NullEngine, 0.0)
1230            .unwrap();
1231        // Chain should drop (second hook)
1232        assert!(exec.hook_result.unwrap().is_drop());
1233        // But injected action from first hook should be present
1234        assert_eq!(exec.injected_actions.len(), 1);
1235    }
1236
1237    // --- Instance persistence tests ---
1238
1239    /// WAT module with a mutable global counter. Each call increments it and
1240    /// writes the counter value into the verdict field (abusing it as an integer).
1241    /// This lets us verify that the global persists across calls.
1242    const WAT_COUNTER: &str = r#"
1243        (module
1244            (memory (export "memory") 1)
1245            (func (export "__rns_abi_version") (result i32) (i32.const 1))
1246            (global $counter (mut i32) (i32.const 0))
1247            (func (export "on_hook") (param i32) (result i32)
1248                ;; Increment counter
1249                (global.set $counter (i32.add (global.get $counter) (i32.const 1)))
1250                ;; Write counter value at 0x3000 (scratch area)
1251                (i32.store (i32.const 0x3000) (global.get $counter))
1252                ;; Return Continue with the counter stashed in modified_data region
1253                ;; verdict = 2 (Modify) so we can extract the counter via modified_data
1254                (i32.store (i32.const 0x2000) (i32.const 2))
1255                ;; modified_data_offset = 0x3000
1256                (i32.store (i32.add (i32.const 0x2000) (i32.const 4)) (i32.const 0x3000))
1257                ;; modified_data_len = 4
1258                (i32.store (i32.add (i32.const 0x2000) (i32.const 8)) (i32.const 4))
1259                (i32.store (i32.add (i32.const 0x2000) (i32.const 12)) (i32.const 0))
1260                (i32.store (i32.add (i32.const 0x2000) (i32.const 16)) (i32.const 0))
1261                (i32.store (i32.add (i32.const 0x2000) (i32.const 20)) (i32.const 0))
1262                (i32.store (i32.add (i32.const 0x2000) (i32.const 24)) (i32.const 0))
1263                (i32.const 0x2000)
1264            )
1265        )
1266    "#;
1267
1268    fn extract_counter(exec: &ExecuteResult) -> u32 {
1269        let data = exec.modified_data.as_ref().expect("no modified data");
1270        assert_eq!(data.len(), 4);
1271        u32::from_le_bytes([data[0], data[1], data[2], data[3]])
1272    }
1273
1274    #[test]
1275    fn instance_persistence_counter() {
1276        let mgr = make_manager();
1277        let mut prog = mgr
1278            .compile("counter".into(), WAT_COUNTER.as_bytes(), 0)
1279            .unwrap();
1280        let ctx = HookContext::Tick;
1281
1282        // Call 3 times — counter should increment across calls
1283        let exec1 = mgr
1284            .execute_program(&mut prog, &ctx, &NullEngine, 0.0, None)
1285            .unwrap();
1286        assert_eq!(extract_counter(&exec1), 1);
1287
1288        let exec2 = mgr
1289            .execute_program(&mut prog, &ctx, &NullEngine, 0.0, None)
1290            .unwrap();
1291        assert_eq!(extract_counter(&exec2), 2);
1292
1293        let exec3 = mgr
1294            .execute_program(&mut prog, &ctx, &NullEngine, 0.0, None)
1295            .unwrap();
1296        assert_eq!(extract_counter(&exec3), 3);
1297    }
1298
1299    #[test]
1300    fn instance_persistence_resets_on_drop_cache() {
1301        let mgr = make_manager();
1302        let mut prog = mgr
1303            .compile("counter".into(), WAT_COUNTER.as_bytes(), 0)
1304            .unwrap();
1305        let ctx = HookContext::Tick;
1306
1307        // Increment twice
1308        mgr.execute_program(&mut prog, &ctx, &NullEngine, 0.0, None)
1309            .unwrap();
1310        let exec2 = mgr
1311            .execute_program(&mut prog, &ctx, &NullEngine, 0.0, None)
1312            .unwrap();
1313        assert_eq!(extract_counter(&exec2), 2);
1314
1315        // Drop cache (simulates reload)
1316        prog.drop_cache();
1317
1318        // Counter should restart at 1
1319        let exec3 = mgr
1320            .execute_program(&mut prog, &ctx, &NullEngine, 0.0, None)
1321            .unwrap();
1322        assert_eq!(extract_counter(&exec3), 1);
1323    }
1324
1325    // --- ABI version validation tests ---
1326
1327    /// WAT module without __rns_abi_version export.
1328    const WAT_NO_ABI_VERSION: &str = r#"
1329        (module
1330            (memory (export "memory") 1)
1331            (func (export "on_hook") (param i32) (result i32)
1332                (i32.store (i32.const 0x2000) (i32.const 0))
1333                (i32.store (i32.add (i32.const 0x2000) (i32.const 4)) (i32.const 0))
1334                (i32.store (i32.add (i32.const 0x2000) (i32.const 8)) (i32.const 0))
1335                (i32.store (i32.add (i32.const 0x2000) (i32.const 12)) (i32.const 0))
1336                (i32.store (i32.add (i32.const 0x2000) (i32.const 16)) (i32.const 0))
1337                (i32.store (i32.add (i32.const 0x2000) (i32.const 20)) (i32.const 0))
1338                (i32.store (i32.add (i32.const 0x2000) (i32.const 24)) (i32.const 0))
1339                (i32.const 0x2000)
1340            )
1341        )
1342    "#;
1343
1344    /// WAT module with wrong ABI version (9999).
1345    const WAT_WRONG_ABI_VERSION: &str = r#"
1346        (module
1347            (memory (export "memory") 1)
1348            (func (export "__rns_abi_version") (result i32) (i32.const 9999))
1349            (func (export "on_hook") (param i32) (result i32)
1350                (i32.store (i32.const 0x2000) (i32.const 0))
1351                (i32.store (i32.add (i32.const 0x2000) (i32.const 4)) (i32.const 0))
1352                (i32.store (i32.add (i32.const 0x2000) (i32.const 8)) (i32.const 0))
1353                (i32.store (i32.add (i32.const 0x2000) (i32.const 12)) (i32.const 0))
1354                (i32.store (i32.add (i32.const 0x2000) (i32.const 16)) (i32.const 0))
1355                (i32.store (i32.add (i32.const 0x2000) (i32.const 20)) (i32.const 0))
1356                (i32.store (i32.add (i32.const 0x2000) (i32.const 24)) (i32.const 0))
1357                (i32.const 0x2000)
1358            )
1359        )
1360    "#;
1361
1362    #[test]
1363    fn rejects_missing_abi_version() {
1364        let mgr = make_manager();
1365        let result = mgr.compile("no_abi".into(), WAT_NO_ABI_VERSION.as_bytes(), 0);
1366        match result {
1367            Err(HookError::AbiVersionMismatch {
1368                hook_name,
1369                expected,
1370                found,
1371            }) => {
1372                assert_eq!(hook_name, "no_abi");
1373                assert_eq!(expected, HOST_ABI_VERSION);
1374                assert_eq!(found, None);
1375            }
1376            other => panic!(
1377                "expected AbiVersionMismatch with found=None, got {:?}",
1378                other.err()
1379            ),
1380        }
1381    }
1382
1383    #[test]
1384    fn rejects_wrong_abi_version() {
1385        let mgr = make_manager();
1386        let result = mgr.compile("bad_abi".into(), WAT_WRONG_ABI_VERSION.as_bytes(), 0);
1387        match result {
1388            Err(HookError::AbiVersionMismatch {
1389                hook_name,
1390                expected,
1391                found,
1392            }) => {
1393                assert_eq!(hook_name, "bad_abi");
1394                assert_eq!(expected, HOST_ABI_VERSION);
1395                assert_eq!(found, Some(9999));
1396            }
1397            other => panic!(
1398                "expected AbiVersionMismatch with found=Some(9999), got {:?}",
1399                other.err()
1400            ),
1401        }
1402    }
1403
1404    #[test]
1405    fn accepts_correct_abi_version() {
1406        let mgr = make_manager();
1407        let result = mgr.compile("good_abi".into(), WAT_CONTINUE.as_bytes(), 0);
1408        assert!(
1409            result.is_ok(),
1410            "compile should succeed with correct ABI version"
1411        );
1412    }
1413
1414    #[test]
1415    fn host_emit_event_collects_provider_event() {
1416        let mgr = make_manager();
1417        let wat = r#"
1418            (module
1419                (import "env" "host_emit_event" (func $emit (param i32 i32 i32 i32) (result i32)))
1420                (memory (export "memory") 1)
1421                (data (i32.const 0x3000) "packet")
1422                (data (i32.const 0x3010) "\01\02\03")
1423                (func (export "__rns_abi_version") (result i32) (i32.const 1))
1424                (func (export "on_hook") (param i32) (result i32)
1425                    (drop (call $emit (i32.const 0x3000) (i32.const 6) (i32.const 0x3010) (i32.const 3)))
1426                    (i32.store (i32.const 0x2000) (i32.const 0))
1427                    (i32.store (i32.add (i32.const 0x2000) (i32.const 4)) (i32.const 0))
1428                    (i32.store (i32.add (i32.const 0x2000) (i32.const 8)) (i32.const 0))
1429                    (i32.store (i32.add (i32.const 0x2000) (i32.const 12)) (i32.const 0))
1430                    (i32.store (i32.add (i32.const 0x2000) (i32.const 16)) (i32.const 0))
1431                    (i32.store (i32.add (i32.const 0x2000) (i32.const 20)) (i32.const 0))
1432                    (i32.store (i32.add (i32.const 0x2000) (i32.const 24)) (i32.const 0))
1433                    (i32.const 0x2000)
1434                )
1435            )
1436        "#;
1437        let module = mgr.runtime.compile(&wat::parse_str(wat).unwrap()).unwrap();
1438        let mut prog = LoadedProgram::new("emit".into(), module, 0);
1439        let ctx = HookContext::Tick;
1440
1441        let exec = mgr
1442            .execute_program_with_provider_events(&mut prog, &ctx, &NullEngine, 0.0, true, None)
1443            .unwrap();
1444        assert_eq!(exec.provider_events.len(), 1);
1445        assert_eq!(exec.provider_events[0].hook_name, "emit");
1446        assert_eq!(exec.provider_events[0].payload_type, "packet");
1447        assert_eq!(exec.provider_events[0].payload, vec![1, 2, 3]);
1448    }
1449
1450    #[test]
1451    fn host_emit_event_is_ignored_when_disabled() {
1452        let mgr = make_manager();
1453        let wat = r#"
1454            (module
1455                (import "env" "host_emit_event" (func $emit (param i32 i32 i32 i32) (result i32)))
1456                (memory (export "memory") 1)
1457                (data (i32.const 0x3000) "packet")
1458                (data (i32.const 0x3010) "\01\02\03")
1459                (func (export "__rns_abi_version") (result i32) (i32.const 1))
1460                (func (export "on_hook") (param i32) (result i32)
1461                    (drop (call $emit (i32.const 0x3000) (i32.const 6) (i32.const 0x3010) (i32.const 3)))
1462                    (i32.store (i32.const 0x2000) (i32.const 0))
1463                    (i32.store (i32.add (i32.const 0x2000) (i32.const 4)) (i32.const 0))
1464                    (i32.store (i32.add (i32.const 0x2000) (i32.const 8)) (i32.const 0))
1465                    (i32.store (i32.add (i32.const 0x2000) (i32.const 12)) (i32.const 0))
1466                    (i32.store (i32.add (i32.const 0x2000) (i32.const 16)) (i32.const 0))
1467                    (i32.store (i32.add (i32.const 0x2000) (i32.const 20)) (i32.const 0))
1468                    (i32.store (i32.add (i32.const 0x2000) (i32.const 24)) (i32.const 0))
1469                    (i32.const 0x2000)
1470                )
1471            )
1472        "#;
1473        let module = mgr.runtime.compile(&wat::parse_str(wat).unwrap()).unwrap();
1474        let mut prog = LoadedProgram::new("emit".into(), module, 0);
1475        let ctx = HookContext::Tick;
1476
1477        let exec = mgr
1478            .execute_program_with_provider_events(&mut prog, &ctx, &NullEngine, 0.0, false, None)
1479            .unwrap();
1480        assert!(exec.provider_events.is_empty());
1481    }
1482
1483    #[test]
1484    fn run_chain_returns_continue_only_provider_events() {
1485        let manager = make_manager();
1486        let wasm = wat::parse_str(
1487            r#"(module
1488                (import "env" "host_emit_event" (func $emit (param i32 i32 i32 i32) (result i32)))
1489                (memory (export "memory") 1)
1490                (data (i32.const 4096) "\00\00\00\00")
1491                (data (i32.const 8192) "packet")
1492                (data (i32.const 8208) "\01\02\03")
1493                (func (export "__rns_abi_version") (result i32) i32.const 1)
1494                (func (export "on_hook") (param i32) (result i32)
1495                    i32.const 8192
1496                    i32.const 6
1497                    i32.const 8208
1498                    i32.const 3
1499                    call $emit
1500                    drop
1501                    i32.const 4096
1502                )
1503            )"#,
1504        )
1505        .unwrap();
1506
1507        let mut programs = vec![manager.compile("emit".into(), &wasm, 0).unwrap()];
1508        let pkt_ctx = crate::PacketContext {
1509            flags: 0,
1510            hops: 0,
1511            destination_hash: [0; 16],
1512            context: 0,
1513            packet_hash: [0; 32],
1514            interface_id: 1,
1515            data_offset: 0,
1516            data_len: 0,
1517        };
1518        let ctx = crate::HookContext::Packet {
1519            ctx: &pkt_ctx,
1520            raw: &[],
1521        };
1522
1523        let exec = manager
1524            .run_chain_with_provider_events(&mut programs, &ctx, &NullEngine, 0.0, true)
1525            .expect("expected provider event result");
1526        assert!(exec.hook_result.is_none());
1527        assert!(exec.injected_actions.is_empty());
1528        assert_eq!(exec.provider_events.len(), 1);
1529        assert_eq!(exec.provider_events[0].payload_type, "packet");
1530        assert_eq!(exec.provider_events[0].payload, vec![1, 2, 3]);
1531    }
1532}