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