Skip to main content

jsdet_core/
sandbox.rs

1use std::sync::{Arc, Mutex};
2use std::time::Instant;
3
4use wasmtime::{Engine, Extern, Linker, Memory, Module, Store, TypedFunc};
5
6use crate::bridge::Bridge;
7use crate::config::SandboxConfig;
8use crate::context::{ContextId, MessageBus};
9use crate::error::{Error, Result};
10use crate::observation::{Observation, ResourceLimitKind};
11
12/// Embedded QuickJS WASM binary  -  compiled from stripped C source.
13const QUICKJS_WASM: &[u8] = include_bytes!("../quickjs.wasm");
14
15/// The pre-compiled QuickJS WASM module.
16///
17/// Created once, reused across executions. Thread-safe.
18/// Each execution instantiates a fresh WASM instance from this module.
19pub struct CompiledModule {
20    engine: Engine,
21    module: Module,
22}
23
24impl CompiledModule {
25    /// Compile the embedded `QuickJS` WASM binary.
26    ///
27    /// This is the expensive operation (~10-50ms). Do it once at startup.
28    /// For repeated runs, use `load_cached()` with a serialized module.
29    ///
30    /// # Errors
31    ///
32    /// Returns an error if the WASM fails to compile or instantiate.
33    pub fn new() -> Result<Self> {
34        let mut engine_config = wasmtime::Config::new();
35        engine_config.consume_fuel(true);
36        engine_config.wasm_bulk_memory(true);
37        // QuickJS-WASM uses the wasm linear stack for its C-side recursion
38        // (parsing, function compilation, GC). Wasmtime's default of ~512KB
39        // is far too small  -  `js_create_function` frames are large because
40        // each carries local arrays for scope vars, and `resolve_scope_var`
41        // calls allocators that need their own headroom. Empirically 4MB
42        // also traps on the chrome-persona bridge bootstrap; 32MB clears
43        // it. Wasmtime requires async_stack_size >= max_wasm_stack.
44        engine_config.async_stack_size(64 * 1024 * 1024);
45        engine_config.max_wasm_stack(32 * 1024 * 1024);
46
47        let engine =
48            Engine::new(&engine_config).map_err(|e| Error::WasmInit(format!("engine: {e}")))?;
49
50        let module = Module::new(&engine, QUICKJS_WASM)
51            .map_err(|e| Error::WasmInit(format!("module: {e}")))?;
52
53        Ok(Self { engine, module })
54    }
55
56    /// Serialize the compiled module to bytes for caching.
57    ///
58    /// Save the result to disk. On next startup, use `load_cached()`
59    /// to skip compilation (~50ms → ~1ms).
60    ///
61    /// # Errors
62    ///
63    /// Returns an error if the underlying module serialization fails.
64    pub fn serialize(&self) -> Result<Vec<u8>> {
65        self.module
66            .serialize()
67            .map_err(|e| Error::WasmInit(format!("serialize: {e}")))
68    }
69
70    /// Load a pre-compiled module from serialized bytes.
71    ///
72    /// # Safety
73    ///
74    /// The serialized bytes must have been produced by `serialize()`
75    /// from the same wasmtime version. Loading tampered bytes is
76    /// memory-safe (wasmtime validates) but may produce unexpected behavior.
77    /// # Errors
78    ///
79    /// Returns an error if the engine or module deserialization fails.
80    pub fn load_cached(bytes: &[u8]) -> Result<Self> {
81        let mut engine_config = wasmtime::Config::new();
82        engine_config.consume_fuel(true);
83        engine_config.wasm_bulk_memory(true);
84        // QuickJS-WASM uses the wasm linear stack for its C-side recursion
85        // (parsing, function compilation, GC). Wasmtime's default of ~512KB
86        // is far too small  -  `js_create_function` frames are large because
87        // each carries local arrays for scope vars, and `resolve_scope_var`
88        // calls allocators that need their own headroom. Empirically 4MB
89        // also traps on the chrome-persona bridge bootstrap; 32MB clears
90        // it. Wasmtime requires async_stack_size >= max_wasm_stack.
91        engine_config.async_stack_size(64 * 1024 * 1024);
92        engine_config.max_wasm_stack(32 * 1024 * 1024);
93
94        let engine =
95            Engine::new(&engine_config).map_err(|e| Error::WasmInit(format!("engine: {e}")))?;
96
97        let module = unsafe {
98            Module::deserialize(&engine, bytes)
99                .map_err(|e| Error::WasmInit(format!("deserialize: {e}")))?
100        };
101
102        Ok(Self { engine, module })
103    }
104
105    /// Compile and cache to disk. On next call, loads from cache.
106    ///
107    /// The cache file carries an 8-byte integrity tag so that trivially
108    /// tampered files are rejected before reaching the `unsafe` deserialize
109    /// path. On Unix, the file is created with mode `0o600`.
110    /// # Errors
111    ///
112    /// Returns an error if compilation fails.
113    pub fn new_cached(cache_path: &std::path::Path) -> Result<Self> {
114        // For security, avoid calling the unsafe `Module::deserialize` on
115        // potentially attacker-controlled cache files. Always compile a
116        // fresh module; write a cache for performance but do NOT read it
117        // back via `deserialize` to prevent unsafe deserialization of
118        // untrusted bytes.
119        let module = Self::new()?;
120        if let Ok(serialized) = module.serialize() {
121            if let Some(parent) = cache_path.parent() {
122                let _ = std::fs::create_dir_all(parent);
123            }
124            let tagged = Self::tag_cache_bytes(&serialized);
125            let _ = std::fs::write(cache_path, &tagged);
126            Self::set_restrictive_permissions(cache_path);
127        }
128        Ok(module)
129    }
130
131    /// Compute a 64-bit integrity hash over `payload` and prepend it as an
132    /// 8-byte little-endian tag. Uses FNV-1a for determinism across processes
133    /// (DefaultHasher uses random seed, breaking cross-process cache sharing).
134    fn tag_cache_bytes(payload: &[u8]) -> Vec<u8> {
135        let tag = Self::fnv1a_hash(payload).to_le_bytes();
136        let mut out = Vec::with_capacity(8 + payload.len());
137        out.extend_from_slice(&tag);
138        out.extend_from_slice(payload);
139        out
140    }
141
142    /// Deterministic FNV-1a hash  -  same result across processes and restarts.
143    fn fnv1a_hash(data: &[u8]) -> u64 {
144        let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
145        for &byte in data {
146            hash ^= u64::from(byte);
147            hash = hash.wrapping_mul(0x0100_0000_01b3);
148        }
149        hash
150    }
151
152    /// Set restrictive permissions on the cache file (Unix: 0o600).
153    fn set_restrictive_permissions(path: &std::path::Path) {
154        #[cfg(unix)]
155        {
156            use std::os::unix::fs::PermissionsExt;
157            let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
158        }
159        let _ = path;
160    }
161
162    /// Get a reference to the wasmtime Engine.
163    #[must_use]
164    pub fn engine(&self) -> &Engine {
165        &self.engine
166    }
167
168    /// Get a reference to the compiled WASM Module.
169    #[must_use]
170    pub fn module_ref(&self) -> &Module {
171        &self.module
172    }
173
174    /// Execute JavaScript in a fresh sandboxed instance.
175    ///
176    /// Each call creates a new WASM instance with isolated memory.
177    /// No state leaks between executions.
178    /// # Errors
179    ///
180    /// Returns an error if WASM execution traps or resources are exhausted.
181    pub fn execute(
182        &self,
183        scripts: &[String],
184        bridge: Arc<dyn Bridge>,
185        config: &SandboxConfig,
186    ) -> Result<ExecutionResult> {
187        self.execute_in_context(scripts, bridge, config, &ContextId::background(), None)
188    }
189
190    /// Execute in a named context with access to the message bus.
191    ///
192    /// # Errors
193    ///
194    /// Returns an error if WASM execution traps or resources are exhausted.
195    #[allow(
196        clippy::too_many_lines,
197        clippy::needless_pass_by_value,
198        clippy::cast_possible_truncation,
199        clippy::cast_possible_wrap
200    )]
201    pub fn execute_in_context(
202        &self,
203        scripts: &[String],
204        bridge: Arc<dyn Bridge>,
205        config: &SandboxConfig,
206        context_id: &ContextId,
207        message_bus: Option<&Mutex<MessageBus>>,
208    ) -> Result<ExecutionResult> {
209        let start = Instant::now();
210        let observations: Arc<Mutex<Vec<Observation>>> = Arc::new(Mutex::new(Vec::new()));
211
212        validate_scripts(scripts, config)?;
213
214        // Build host state.
215        let host = HostState {
216            bridge: bridge.clone(),
217            observations: observations.clone(),
218            context_id: context_id.clone(),
219            config: config.clone(),
220        };
221
222        let mut store = Store::new(&self.engine, host);
223
224        if config.max_fuel > 0 {
225            store
226                .set_fuel(config.max_fuel)
227                .map_err(|e| Error::WasmInit(format!("fuel: {e}")))?;
228        }
229
230        // Instantiate with host-imported functions.
231        let linker = build_linker(&self.engine, &bridge, &observations)?;
232        let instance = linker
233            .instantiate(&mut store, &self.module)
234            .map_err(|e| Error::WasmInit(format!("instantiate: {e}")))?;
235
236        // Get exports.
237        let jsdet_init: TypedFunc<(i32, i32), i32> = instance
238            .get_typed_func(&mut store, "jsdet_init")
239            .map_err(|e| Error::WasmInit(format!("missing jsdet_init export: {e}")))?;
240        let jsdet_eval: TypedFunc<(i32, i32), i32> = instance
241            .get_typed_func(&mut store, "jsdet_eval")
242            .map_err(|e| Error::WasmInit(format!("missing jsdet_eval export: {e}")))?;
243        let jsdet_alloc: TypedFunc<i32, i32> =
244            instance
245                .get_typed_func(&mut store, "jsdet_alloc")
246                .map_err(|e| Error::WasmInit(format!("missing jsdet_alloc export: {e}")))?;
247        let jsdet_free: TypedFunc<i32, ()> = instance
248            .get_typed_func(&mut store, "jsdet_free")
249            .map_err(|e| Error::WasmInit(format!("missing jsdet_free export: {e}")))?;
250        let jsdet_destroy: TypedFunc<(), ()> = instance
251            .get_typed_func(&mut store, "jsdet_destroy")
252            .map_err(|e| Error::WasmInit(format!("missing jsdet_destroy export: {e}")))?;
253
254        let memory = instance
255            .exports(&mut store)
256            .find_map(wasmtime::Export::into_memory)
257            .ok_or_else(|| Error::WasmInit("no memory export".into()))?;
258
259        // Initialize QuickJS runtime.
260        // Cap at i32::MAX to prevent overflow  -  WASM linear memory is 32-bit anyway.
261        let qjs_memory_limit = config.max_memory_bytes.min(i32::MAX as usize) as i32;
262        if qjs_memory_limit == 0 {
263            return Err(Error::MemoryExceeded { limit_bytes: 0 });
264        }
265        let qjs_stack_size = (config.max_memory_bytes / 4).min(i32::MAX as usize) as i32;
266        let init_result = jsdet_init
267            .call(&mut store, (qjs_memory_limit, qjs_stack_size))
268            .map_err(|e| Error::WasmInit(format!("jsdet_init trapped: {e}")))?;
269        if init_result != 0 {
270            return Err(Error::WasmInit(format!(
271                "jsdet_init returned error code {init_result}"
272            )));
273        }
274
275        // Run bootstrap JS (bridge API installation).
276        let bootstrap = bridge.bootstrap_js();
277        if !bootstrap.is_empty() {
278            eval_in_wasm(
279                &mut store,
280                &memory,
281                &jsdet_eval,
282                &jsdet_alloc,
283                &jsdet_free,
284                &bootstrap,
285            )?;
286        }
287
288        // Deliver pending messages from other contexts.
289        if let Some(bus) = message_bus
290            && let Ok(mut bus) = bus.lock()
291        {
292            let pending = bus.receive(context_id);
293            for msg in pending {
294                let dispatch = format!(
295                    "if(typeof __jsdet_dispatch_message==='function')__jsdet_dispatch_message({});",
296                    serde_json::to_string(&msg.payload).unwrap_or_default()
297                );
298                let _ = eval_in_wasm(
299                    &mut store,
300                    &memory,
301                    &jsdet_eval,
302                    &jsdet_alloc,
303                    &jsdet_free,
304                    &dispatch,
305                );
306            }
307        }
308
309        // Execute user scripts.
310        let mut scripts_executed = 0;
311        let mut errors = Vec::new();
312
313        for (i, script) in scripts.iter().enumerate() {
314            if scripts_executed >= config.max_scripts {
315                push_observation(
316                    &observations,
317                    Observation::ResourceLimit {
318                        kind: ResourceLimitKind::ScriptCount,
319                        detail: format!("exceeded {} script limit", config.max_scripts),
320                    },
321                );
322                break;
323            }
324
325            if u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX) >= config.timeout_ms {
326                push_observation(
327                    &observations,
328                    Observation::ResourceLimit {
329                        kind: ResourceLimitKind::Timeout,
330                        detail: format!("exceeded {}ms timeout", config.timeout_ms),
331                    },
332                );
333                break;
334            }
335
336            let fuel_before = if config.max_fuel > 0 {
337                store.get_fuel().ok()
338            } else {
339                None
340            };
341
342            match eval_in_wasm(
343                &mut store,
344                &memory,
345                &jsdet_eval,
346                &jsdet_alloc,
347                &jsdet_free,
348                script,
349            ) {
350                Ok(()) => {
351                    scripts_executed += 1;
352                    if let (Some(before), Ok(after)) = (fuel_before, store.get_fuel()) {
353                        let consumed = before.saturating_sub(after);
354                        if consumed >= config.max_fuel.saturating_mul(9) / 10 {
355                            push_observation(
356                                &observations,
357                                Observation::ResourceLimit {
358                                    kind: ResourceLimitKind::Fuel,
359                                    detail: format!(
360                                        "consumed {consumed} of {} fuel units without trap",
361                                        config.max_fuel
362                                    ),
363                                },
364                            );
365                        }
366                    }
367                    if u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX)
368                        >= config.timeout_ms
369                    {
370                        push_observation(
371                            &observations,
372                            Observation::ResourceLimit {
373                                kind: ResourceLimitKind::Timeout,
374                                detail: format!(
375                                    "single script exceeded {}ms timeout budget",
376                                    config.timeout_ms
377                                ),
378                            },
379                        );
380                        break;
381                    }
382                }
383                Err(Error::FuelExhausted { budget }) => {
384                    push_observation(
385                        &observations,
386                        Observation::ResourceLimit {
387                            kind: ResourceLimitKind::Fuel,
388                            detail: format!("exceeded {budget} fuel budget"),
389                        },
390                    );
391                    break;
392                }
393                Err(Error::MemoryExceeded { limit_bytes }) => {
394                    push_observation(
395                        &observations,
396                        Observation::ResourceLimit {
397                            kind: ResourceLimitKind::Memory,
398                            detail: format!("exceeded {limit_bytes} byte memory budget"),
399                        },
400                    );
401                    break;
402                }
403                Err(e) => {
404                    errors.push(format!("script[{i}]: {e}"));
405                    push_observation(
406                        &observations,
407                        Observation::Error {
408                            message: format!("{e}"),
409                            script_index: Some(i),
410                        },
411                    );
412                    scripts_executed += 1;
413                    if u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX)
414                        >= config.timeout_ms
415                    {
416                        push_observation(
417                            &observations,
418                            Observation::ResourceLimit {
419                                kind: ResourceLimitKind::Timeout,
420                                detail: format!(
421                                    "single script exceeded {}ms timeout budget",
422                                    config.timeout_ms
423                                ),
424                            },
425                        );
426                        break;
427                    }
428                }
429            }
430        }
431
432        // Drain timers if configured.
433        if config.drain_timers {
434            for _ in 0..config.max_timer_drains {
435                if u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX)
436                    >= config.timeout_ms
437                {
438                    break;
439                }
440                let drain_result = eval_in_wasm(
441                    &mut store,
442                    &memory,
443                    &jsdet_eval,
444                    &jsdet_alloc,
445                    &jsdet_free,
446                    "if(typeof __jsdet_drain_timer==='function')__jsdet_drain_timer()",
447                );
448                if drain_result.is_err() {
449                    break;
450                }
451            }
452        }
453
454        // Probe forms  -  after all scripts and timers, find credential forms
455        // and simulate submission to observe where data goes.
456        let _ = eval_in_wasm(
457            &mut store,
458            &memory,
459            &jsdet_eval,
460            &jsdet_alloc,
461            &jsdet_free,
462            "if(typeof __jsdet_probe_forms==='function')__jsdet_probe_forms()",
463        );
464
465        // Second timer drain  -  catch timers set by form submit handlers.
466        // Phishing kits commonly do: form.onsubmit → fetch(c2) → setTimeout(redirect, 500)
467        // The redirect fires in the setTimeout AFTER form submission.
468        if config.drain_timers {
469            for _ in 0..5 {
470                if u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX)
471                    >= config.timeout_ms
472                {
473                    break;
474                }
475                let drain_result = eval_in_wasm(
476                    &mut store,
477                    &memory,
478                    &jsdet_eval,
479                    &jsdet_alloc,
480                    &jsdet_free,
481                    "if(typeof __jsdet_drain_timer==='function')__jsdet_drain_timer()",
482                );
483                if drain_result.is_err() {
484                    break;
485                }
486            }
487        }
488
489        // Clean up QuickJS state.
490        let _ = jsdet_destroy.call(&mut store, ());
491
492        let duration_us = u64::try_from(start.elapsed().as_micros()).unwrap_or(u64::MAX);
493        let collected = observations
494            .lock()
495            .unwrap_or_else(std::sync::PoisonError::into_inner)
496            .clone();
497
498        Ok(ExecutionResult {
499            observations: collected,
500            scripts_executed,
501            errors,
502            duration_us,
503            timed_out: u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX)
504                >= config.timeout_ms,
505        })
506    }
507
508    /// Snapshot the WASM linear memory for state save/restore.
509    ///
510    /// Returns a byte vector that can be restored later.
511    /// Cost: ~1μs for a 4MB instance (just memcpy).
512    #[must_use]
513    pub fn snapshot_memory(store: &Store<HostState>, memory: &Memory) -> Vec<u8> {
514        memory.data(store).to_vec()
515    }
516
517    /// Restore WASM linear memory from a snapshot.
518    pub fn restore_memory(store: &mut Store<HostState>, memory: &Memory, snapshot: &[u8]) {
519        let data = memory.data_mut(store);
520        let len = snapshot.len().min(data.len());
521        data[..len].copy_from_slice(&snapshot[..len]);
522    }
523}
524
525/// The result of executing JavaScript in the sandbox.
526#[derive(Debug, Clone)]
527pub struct ExecutionResult {
528    /// All observations collected during execution, in order.
529    pub observations: Vec<Observation>,
530    /// Number of scripts that executed (including those that errored).
531    pub scripts_executed: usize,
532    /// Errors encountered during execution.
533    pub errors: Vec<String>,
534    /// Total wall-clock time in microseconds.
535    pub duration_us: u64,
536    /// Whether execution was terminated by a resource limit.
537    pub timed_out: bool,
538}
539
540/// Host state accessible from WASM imported functions.
541///
542/// The `bridge` and `observations` fields are accessed via `Arc` clones
543/// captured by the linker closures, not through the struct directly.
544/// The struct owns them to keep them alive for the store's lifetime.
545#[allow(dead_code)]
546pub struct HostState {
547    pub bridge: Arc<dyn Bridge>,
548    pub observations: Arc<Mutex<Vec<Observation>>>,
549    pub context_id: ContextId,
550    pub config: SandboxConfig,
551}
552
553fn validate_scripts(scripts: &[String], config: &SandboxConfig) -> Result<()> {
554    let total_bytes: usize = scripts.iter().map(String::len).sum();
555    if total_bytes > config.max_total_script_bytes {
556        return Err(Error::Internal(format!(
557            "total script size {} exceeds limit {}",
558            total_bytes, config.max_total_script_bytes
559        )));
560    }
561    for (i, script) in scripts.iter().enumerate() {
562        if script.len() > config.max_script_bytes {
563            return Err(Error::Internal(format!(
564                "script[{i}] size {} exceeds limit {}",
565                script.len(),
566                config.max_script_bytes
567            )));
568        }
569    }
570    Ok(())
571}
572
573pub fn push_observation(observations: &Arc<Mutex<Vec<Observation>>>, obs: Observation) {
574    if let Ok(mut guard) = observations.lock() {
575        guard.push(obs);
576    }
577}
578
579/// Build the linker with host-imported functions.
580///
581/// These are the ONLY functions the WASM module can call.
582/// The `jsdet` import module provides `bridge_call` and observe.
583/// WASI imports are stubbed to satisfy wasi-libc dependencies.
584///
585/// # Errors
586/// Returns an error if the exports cannot be resolved.
587#[allow(
588    clippy::too_many_lines,
589    clippy::cast_sign_loss,
590    clippy::cast_possible_truncation
591)]
592pub fn build_linker(
593    engine: &Engine,
594    bridge: &Arc<dyn Bridge>,
595    observations: &Arc<Mutex<Vec<Observation>>>,
596) -> Result<Linker<HostState>> {
597    let mut linker = Linker::new(engine);
598
599    // Stub the 4 WASI imports QuickJS needs. No real I/O  -  these satisfy
600    // wasi-libc link requirements without granting any capabilities.
601    // clock_time_get: returns monotonic nanoseconds from fuel counter.
602    linker
603        .func_wrap(
604            "wasi_snapshot_preview1",
605            "clock_time_get",
606            |mut caller: wasmtime::Caller<'_, HostState>,
607             _clock_id: i32,
608             _precision: i64,
609             result_ptr: i32|
610             -> i32 {
611                // Return a plausible fixed timestamp to defeat timing-based sandbox detection.
612                // Date.now() returning 0 (epoch) is a dead giveaway. Instead, return
613                // a recent-looking timestamp (2025-01-01T00:00:00Z in nanoseconds).
614                // Deterministic: same value every time, no real clock access.
615                const FAKE_TIME_NS: u64 = 1_735_689_600_000_000_000; // 2025-01-01 UTC
616                if let Some(memory) = caller.get_export("memory").and_then(Extern::into_memory) {
617                    let ptr = result_ptr as usize;
618                    let data = memory.data_mut(&mut caller);
619                    if ptr.checked_add(8).is_some_and(|end| end <= data.len()) {
620                        data[ptr..ptr + 8].copy_from_slice(&FAKE_TIME_NS.to_le_bytes());
621                    }
622                }
623                0 // success
624            },
625        )
626        .map_err(|e| Error::WasmInit(format!("wasi clock: {e}")))?;
627
628    // fd_close: no-op, return success.
629    linker
630        .func_wrap(
631            "wasi_snapshot_preview1",
632            "fd_close",
633            |_caller: wasmtime::Caller<'_, HostState>, _fd: i32| -> i32 { 0 },
634        )
635        .map_err(|e| Error::WasmInit(format!("wasi fd_close: {e}")))?;
636
637    // fd_fdstat_get: return regular file stats for stdout/stderr.
638    linker
639        .func_wrap(
640            "wasi_snapshot_preview1",
641            "fd_fdstat_get",
642            |_: wasmtime::Caller<'_, HostState>, _: i32, _: i32| -> i32 { 0 },
643        )
644        .map_err(|e| Error::WasmInit(format!("wasi fd_fdstat_get: {e}")))?;
645
646    // fd_seek: not supported, return errno 76 (ENOTSUP).
647    linker
648        .func_wrap(
649            "wasi_snapshot_preview1",
650            "fd_seek",
651            |_caller: wasmtime::Caller<'_, HostState>,
652             _fd: i32,
653             _offset: i64,
654             _whence: i32,
655             _newoffset: i32|
656             -> i32 { 76 },
657        )
658        .map_err(|e| Error::WasmInit(format!("wasi fd_seek: {e}")))?;
659
660    // fd_write: capture stderr/stdout output as observations.
661    linker
662        .func_wrap(
663            "wasi_snapshot_preview1",
664            "fd_write",
665            move |mut caller: wasmtime::Caller<'_, HostState>,
666                  _fd: i32,
667                  iovs_ptr: i32,
668                  iovs_len: i32,
669                  nwritten_ptr: i32|
670                  -> i32 {
671                // Read iovec structures from WASM memory to capture output.
672                let memory = caller.get_export("memory").and_then(Extern::into_memory);
673                let Some(memory) = memory else { return 8 }; // EBADF
674                let data = memory.data(&caller);
675
676                let mut total = 0u32;
677                for i in 0..iovs_len {
678                    let Some(iov_offset) =
679                        (iovs_ptr as usize).checked_add((i as usize).saturating_mul(8))
680                    else {
681                        break;
682                    };
683                    let Some(iov_end) = iov_offset.checked_add(8) else {
684                        break;
685                    };
686                    if iov_end > data.len() {
687                        break;
688                    }
689                    // Safe: bounds checked on line above (iov_offset + 8 <= data.len())
690                    // Read the 4-byte fields safely and return EINVAL (22) on conversion failure instead of panicking.
691                    let _buf_ptr = match data[iov_offset..iov_offset + 4].try_into() {
692                        Ok(b) => u32::from_le_bytes(b),
693                        Err(_) => return 22, // EINVAL
694                    };
695                    let buf_len = match data[iov_offset + 4..iov_offset + 8].try_into() {
696                        Ok(b) => u32::from_le_bytes(b),
697                        Err(_) => return 22, // EINVAL
698                    };
699                    total += buf_len;
700                }
701
702                // Write nwritten.
703                let nw_offset = nwritten_ptr as usize;
704                if nw_offset
705                    .checked_add(4)
706                    .is_some_and(|end| end <= memory.data(&caller).len())
707                {
708                    let bytes = total.to_le_bytes();
709                    memory.data_mut(&mut caller)[nw_offset..nw_offset + 4].copy_from_slice(&bytes);
710                }
711
712                0 // success (output is silently consumed)
713            },
714        )
715        .map_err(|e| Error::WasmInit(format!("wasi fd_write: {e}")))?;
716
717    // jsdet.bridge_call  -  the bridge function dispatcher.
718    // Routes JS API calls through the Bridge trait to the consumer's implementation.
719    let bridge_clone = bridge.clone();
720    let obs_clone = observations.clone();
721    linker
722        .func_wrap(
723            "jsdet",
724            "bridge_call",
725            move |mut caller: wasmtime::Caller<'_, HostState>,
726                  api_ptr: i32,
727                  api_len: i32,
728                  args_ptr: i32,
729                  args_len: i32|
730                  -> i32 {
731                let memory = caller.get_export("memory").and_then(Extern::into_memory);
732                let Some(memory) = memory else { return 0 };
733                let data = memory.data(&caller);
734
735                let api = read_string(data, api_ptr as usize, api_len as usize);
736                let args_json = read_string(data, args_ptr as usize, args_len as usize);
737
738                // Parse args from JSON.
739                let args: Vec<crate::observation::Value> = serde_json::from_str(&args_json)
740                    .unwrap_or_else(|_| {
741                        if args_json.is_empty() {
742                            vec![]
743                        } else {
744                            vec![crate::observation::Value::string(args_json.clone())]
745                        }
746                    });
747
748                // Dispatch through the Bridge trait.
749                let result = bridge_clone.call(&api, &args);
750
751                // Record the observation.
752                let result_value = match &result {
753                    Ok(v) => v.clone(),
754                    Err(e) => crate::observation::Value::string(format!("Error: {e}")),
755                };
756                if let Ok(mut guard) = obs_clone.lock() {
757                    guard.push(Observation::ApiCall {
758                        api: api.clone(),
759                        args,
760                        result: result_value.clone(),
761                    });
762                }
763
764                // Write return value to WASM memory via dynamic allocation.
765                // The C glue reads it back and converts to a JSValue.
766                if let Ok(ref value) = result {
767                    let json = value_to_json(value);
768                    let json_bytes = json.as_bytes();
769                    let write_len = json_bytes.len();
770
771                    // Allocate a return buffer in WASM memory.
772                    if let Ok(alloc_ret) = caller
773                        .get_export("jsdet_alloc_return")
774                        .and_then(Extern::into_func)
775                        .ok_or(())
776                        .and_then(|f| f.typed::<i32, i32>(&caller).map_err(|_| ()))
777                        && let Ok(buf_ptr) = alloc_ret.call(&mut caller, write_len as i32)
778                        && buf_ptr != 0
779                    {
780                        let buf_ptr = buf_ptr as usize;
781                        let mem = caller.get_export("memory").and_then(Extern::into_memory);
782                        if let Some(mem) = mem {
783                            let data = mem.data_mut(&mut caller);
784                            if buf_ptr
785                                .checked_add(write_len)
786                                .is_some_and(|end| end < data.len())
787                            {
788                                data[buf_ptr..buf_ptr + write_len].copy_from_slice(json_bytes);
789                            }
790                        }
791
792                        if let Ok(set_len) = caller
793                            .get_export("jsdet_set_return_len")
794                            .and_then(Extern::into_func)
795                            .ok_or(())
796                            .and_then(|f| f.typed::<i32, ()>(&caller).map_err(|_| ()))
797                        {
798                            let _ = set_len.call(&mut caller, write_len as i32);
799                        }
800                    }
801                }
802
803                if result.is_ok() { 0 } else { -1 }
804            },
805        )
806        .map_err(|e| Error::WasmInit(format!("bridge_call link: {e}")))?;
807
808    // jsdet.observe  -  direct observation recording from WASM.
809    let obs_clone2 = observations.clone();
810    linker
811        .func_wrap(
812            "jsdet",
813            "observe",
814            move |mut caller: wasmtime::Caller<'_, HostState>,
815                  kind: i32,
816                  data_ptr: i32,
817                  data_len: i32| {
818                let memory = caller.get_export("memory").and_then(Extern::into_memory);
819                let Some(memory) = memory else { return };
820                let mem_data = memory.data(&caller);
821                let detail = read_string(mem_data, data_ptr as usize, data_len as usize);
822
823                let observation = match kind {
824                    9 => Observation::Error {
825                        message: detail,
826                        script_index: None,
827                    },
828                    _ => Observation::ApiCall {
829                        api: format!("__observe_kind_{kind}"),
830                        args: vec![crate::observation::Value::string(detail)],
831                        result: crate::observation::Value::Undefined,
832                    },
833                };
834
835                if let Ok(mut guard) = obs_clone2.lock() {
836                    guard.push(observation);
837                }
838            },
839        )
840        .map_err(|e| Error::WasmInit(format!("observe link: {e}")))?;
841
842    Ok(linker)
843}
844
845/// Evaluate a script by writing it to WASM memory and calling jsdet_eval.
846pub fn eval_in_wasm(
847    store: &mut Store<HostState>,
848    memory: &Memory,
849    jsdet_eval: &TypedFunc<(i32, i32), i32>,
850    jsdet_alloc: &TypedFunc<i32, i32>,
851    jsdet_free: &TypedFunc<i32, ()>,
852    script: &str,
853) -> Result<()> {
854    let bytes = script.as_bytes();
855    if bytes.len() >= i32::MAX as usize {
856        return Err(Error::Trap(
857            "script exceeds WASM 32-bit address space".into(),
858        ));
859    }
860    // Allocate +1 for null terminator  -  QuickJS may read past the length
861    // in some internal string functions.
862    let alloc_len = (bytes.len() as i32) + 1;
863
864    // Allocate WASM memory for the script.
865    let ptr = jsdet_alloc
866        .call(&mut *store, alloc_len)
867        .map_err(|e| Error::Trap(format!("alloc: {e}")))?;
868    if ptr == 0 {
869        return Err(Error::MemoryExceeded {
870            limit_bytes: store.data().config.max_memory_bytes,
871        });
872    }
873
874    // Write script bytes + null terminator to WASM memory.
875    {
876        let mem_data = memory.data_mut(&mut *store);
877        let start = ptr as usize;
878        let Some(end) = start.checked_add(bytes.len()) else {
879            return Err(Error::MemoryExceeded {
880                limit_bytes: mem_data.len(),
881            });
882        };
883        if end < mem_data.len() {
884            mem_data[start..end].copy_from_slice(bytes);
885            mem_data[end] = 0; // null terminator
886        } else {
887            return Err(Error::MemoryExceeded {
888                limit_bytes: mem_data.len(),
889            });
890        }
891    }
892
893    // Call jsdet_eval with the actual script length (not including null terminator).
894    let len = bytes.len() as i32;
895    let result = jsdet_eval.call(&mut *store, (ptr, len));
896
897    // Free the script memory.
898    let _ = jsdet_free.call(&mut *store, ptr);
899
900    match result {
901        Ok(0) => Ok(()),
902        Ok(code) => Err(Error::Trap(format!("jsdet_eval returned error {code}"))),
903        Err(e) => {
904            let msg = e.to_string();
905            if msg.contains("fuel") {
906                Err(Error::FuelExhausted {
907                    budget: store.data().config.max_fuel,
908                })
909            } else if msg.contains("js_array_constructor")
910                || msg.contains("out of bounds memory access")
911                || msg.contains("memory access out of bounds")
912                || msg.contains("allocation too large")
913                || msg.contains("memory allocation failed")
914            {
915                Err(Error::MemoryExceeded {
916                    limit_bytes: store.data().config.max_memory_bytes,
917                })
918            } else {
919                Err(Error::Trap(msg))
920            }
921        }
922    }
923}
924
925/// Convert a Value to JSON for the bridge return buffer.
926fn value_to_json(value: &crate::observation::Value) -> String {
927    match value {
928        crate::observation::Value::Undefined => "undefined".into(),
929        crate::observation::Value::Null => "null".into(),
930        crate::observation::Value::Bool(b) => if *b { "true" } else { "false" }.into(),
931        crate::observation::Value::Int(n) => n.to_string(),
932        crate::observation::Value::Float(n) => n.to_string(),
933        crate::observation::Value::String(s, _) => serde_json::to_string(s).unwrap_or_default(),
934        crate::observation::Value::Json(j, _) => j.clone(),
935        crate::observation::Value::Bytes(_) => "null".into(),
936    }
937}
938
939/// Read a UTF-8 string from WASM linear memory.
940fn read_string(data: &[u8], ptr: usize, len: usize) -> String {
941    let Some(end) = ptr.checked_add(len) else {
942        return String::new(); // overflow guard
943    };
944    if end > data.len() {
945        return String::new();
946    }
947    String::from_utf8_lossy(&data[ptr..end]).into_owned()
948}