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
12const QUICKJS_WASM: &[u8] = include_bytes!("../quickjs.wasm");
14
15pub struct CompiledModule {
20 engine: Engine,
21 module: Module,
22}
23
24impl CompiledModule {
25 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 pub fn serialize(&self) -> Result<Vec<u8>> {
56 self.module
57 .serialize()
58 .map_err(|e| Error::WasmInit(format!("serialize: {e}")))
59 }
60
61 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 pub fn new_cached(cache_path: &std::path::Path) -> Result<Self> {
96 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 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 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 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 #[must_use]
146 pub fn engine(&self) -> &Engine {
147 &self.engine
148 }
149
150 #[must_use]
152 pub fn module_ref(&self) -> &Module {
153 &self.module
154 }
155
156 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 #[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 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 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 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 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 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 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 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 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 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 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 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 #[must_use]
429 pub fn snapshot_memory(store: &Store<HostState>, memory: &Memory) -> Vec<u8> {
430 memory.data(store).to_vec()
431 }
432
433 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#[derive(Debug, Clone)]
443pub struct ExecutionResult {
444 pub observations: Vec<Observation>,
446 pub scripts_executed: usize,
448 pub errors: Vec<String>,
450 pub duration_us: u64,
452 pub timed_out: bool,
454}
455
456#[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#[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 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 const FAKE_TIME_NS: u64 = 1_735_689_600_000_000_000; 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 },
541 )
542 .map_err(|e| Error::WasmInit(format!("wasi clock: {e}")))?;
543
544 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 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 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 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 let memory = caller.get_export("memory").and_then(Extern::into_memory);
589 let Some(memory) = memory else { return 8 }; 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 let _buf_ptr = match data[iov_offset..iov_offset + 4].try_into() {
608 Ok(b) => u32::from_le_bytes(b),
609 Err(_) => return 22, };
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, };
615 total += buf_len;
616 }
617
618 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 },
630 )
631 .map_err(|e| Error::WasmInit(format!("wasi fd_write: {e}")))?;
632
633 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 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 let result = bridge_clone.call(&api, &args);
666
667 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 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 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 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
761pub 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 let alloc_len = (bytes.len() as i32) + 1;
779
780 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 {
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; } else {
803 return Err(Error::MemoryExceeded {
804 limit_bytes: mem_data.len(),
805 });
806 }
807 }
808
809 let len = bytes.len() as i32;
811 let result = jsdet_eval.call(&mut *store, (ptr, len));
812
813 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
832fn 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
846fn read_string(data: &[u8], ptr: usize, len: usize) -> String {
848 let Some(end) = ptr.checked_add(len) else {
849 return String::new(); };
851 if end > data.len() {
852 return String::new();
853 }
854 String::from_utf8_lossy(&data[ptr..end]).into_owned()
855}