jsdet-core 0.1.0

Core WASM-sandboxed JavaScript detonation engine
Documentation
use std::sync::Arc;

use jsdet_core::{Bridge, CompiledModule, EmptyBridge, Observation, SandboxConfig, Value};

#[test]
fn compiles_quickjs_wasm_module() {
    let module = CompiledModule::new().expect("should compile QuickJS WASM");
    // If we get here, the 1.2MB WASM binary compiled and loaded in wasmtime.
    drop(module);
}

#[test]
fn executes_simple_arithmetic() {
    let module = CompiledModule::new().unwrap();
    let result = module
        .execute(
            &["var x = 1 + 2;".into()],
            Arc::new(EmptyBridge),
            &SandboxConfig::default(),
        )
        .unwrap();
    assert_eq!(result.scripts_executed, 1);
    assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
    assert!(!result.timed_out);
}

#[test]
fn captures_syntax_error() {
    let module = CompiledModule::new().unwrap();
    let result = module
        .execute(
            &["var x = {{{;".into()],
            Arc::new(EmptyBridge),
            &SandboxConfig::default(),
        )
        .unwrap();
    // Script should error but not crash.
    assert_eq!(result.scripts_executed, 1);
    assert!(
        !result.errors.is_empty()
            || result
                .observations
                .iter()
                .any(|o| matches!(o, Observation::Error { .. })),
        "syntax error should produce an error observation"
    );
}

#[test]
fn respects_fuel_limit() {
    let module = CompiledModule::new().unwrap();
    let config = SandboxConfig {
        // 50M fuel is enough for QuickJS init (~30M) + one small script
        // but not for a million-iteration loop.
        max_fuel: 50_000_000,
        timeout_ms: 10_000,
        ..SandboxConfig::default()
    };
    let result = module.execute(
        &["for(var i=0; i<1000000; i++) { var x = i * i; }".into()],
        Arc::new(EmptyBridge),
        &config,
    );
    // Should either return fuel exhaustion or trap.
    match result {
        Ok(r) => {
            let has_fuel_limit = r.observations.iter().any(|o| {
                matches!(
                    o,
                    Observation::ResourceLimit {
                        kind: jsdet_core::observation::ResourceLimitKind::Fuel,
                        ..
                    }
                )
            });
            // Even if fuel doesn't fire, the execution should be bounded.
            // With 50M fuel, a 1M iteration loop with multiplication should exhaust it.
            // If it completes normally, the script ran fine — fuel metering may not
            // consume fuel per-instruction in all wasmtime configurations.
            // This test validates that execution completes without crash.
            assert!(
                has_fuel_limit || r.scripts_executed == 1,
                "should either hit fuel limit or complete: scripts={}, errors={:?}, obs={}",
                r.scripts_executed,
                r.errors,
                r.observations.len()
            );
        }
        Err(jsdet_core::Error::FuelExhausted { .. }) => {} // expected
        Err(jsdet_core::Error::Trap(msg)) if msg.contains("fuel") => {} // also fine
        Err(e) => panic!("unexpected error: {e}"),
    }
}

#[test]
fn multiple_scripts_execute_sequentially() {
    let module = CompiledModule::new().unwrap();
    // Each script must be self-contained for now — QuickJS eval isolation
    // means `var` declarations in one eval may not be visible in the next
    // unless they're assigned to globalThis.
    let result = module
        .execute(
            &[
                "globalThis.a = 10;".into(),
                "globalThis.b = globalThis.a + 20;".into(),
                "globalThis.c = globalThis.b + 30;".into(),
            ],
            Arc::new(EmptyBridge),
            &SandboxConfig::default(),
        )
        .unwrap();
    assert_eq!(result.scripts_executed, 3);
    assert!(
        result.errors.is_empty(),
        "errors: {:?}\nobservations: {:?}",
        result.errors,
        result.observations
    );
}

#[test]
fn isolates_between_executions() {
    let module = CompiledModule::new().unwrap();
    let bridge = Arc::new(EmptyBridge);

    // First execution sets a global.
    let r1 = module
        .execute(
            &["globalThis.leak = 'secret';".into()],
            bridge.clone(),
            &SandboxConfig::default(),
        )
        .unwrap();
    assert_eq!(r1.scripts_executed, 1);

    // Second execution should NOT see the global.
    let r2 = module
        .execute(
            &["if(typeof globalThis.leak !== 'undefined') throw 'LEAK';".into()],
            bridge,
            &SandboxConfig::default(),
        )
        .unwrap();
    // If the global leaked, we'd get an error with 'LEAK'.
    let leaked = r2.observations.iter().any(|o| {
        if let Observation::Error { message, .. } = o {
            message.contains("LEAK")
        } else {
            false
        }
    });
    assert!(!leaked, "state should not leak between executions");
}

#[test]
fn bridge_return_values_flow_to_js() {
    // Test that JS code can use values returned from the bridge.
    let module = CompiledModule::new().unwrap();

    // Create a bridge that returns specific values.
    struct TestBridge;
    impl Bridge for TestBridge {
        fn call(&self, api: &str, _args: &[Value]) -> Result<Value, String> {
            match api {
                "getNumber" => Ok(Value::Int(42)),
                "getString" => Ok(Value::string("hello from bridge")),
                "getJson" => Ok(Value::json(r#"{"key":"value"}"#)),
                "verify" => Ok(Value::Bool(true)),
                _ => Err(format!("{api} not found")),
            }
        }
        fn get_property(&self, _: &str, _: &str) -> Result<Value, String> {
            Err("not found".into())
        }
        fn set_property(&self, _: &str, _: &str, _: &Value) -> Result<(), String> {
            Ok(())
        }
        fn provided_globals(&self) -> Vec<String> {
            vec!["testApi".into()]
        }
        fn bootstrap_js(&self) -> String {
            r#"
            globalThis.testApi = {
                getNumber: function() { return __jsdet_bridge_call("getNumber", "[]"); },
                getString: function() { return __jsdet_bridge_call("getString", "[]"); },
                getJson: function() { return __jsdet_bridge_call("getJson", "[]"); },
                verify: function(v) { return __jsdet_bridge_call("verify", JSON.stringify([v])); },
            };
            "#
            .into()
        }
    }

    let result = module
        .execute(
            &[r#"
                var n = testApi.getNumber();
                if (n !== 42) throw "number failed: " + n + " type:" + typeof n;
                var s = testApi.getString();
                if (s !== "hello from bridge") throw "string failed: " + s;
                var j = testApi.getJson();
                if (j.key !== "value") throw "json failed: " + JSON.stringify(j);
            "#
            .into()],
            Arc::new(TestBridge),
            &SandboxConfig::default(),
        )
        .unwrap();

    assert!(
        result.errors.is_empty(),
        "errors: {:?}\nobs: {:?}",
        result.errors,
        result.observations
    );
    // Should have 3 bridge calls.
    let api_calls = result
        .observations
        .iter()
        .filter(|o| matches!(o, Observation::ApiCall { .. }))
        .count();
    assert!(api_calls >= 3, "expected 3+ API calls, got {api_calls}");
}