use std::collections::{BTreeMap, BTreeSet};
use bytes::Bytes;
use wasmtime::{Caller, Engine, Linker, Module};
use super::wasmtime_host::HookHostError;
use super::CapToken;
use crate::wasm_runtime_common::{
read_caller_memory, scan_module_imports, write_caller_memory, ScanImportsError,
};
pub const ALLOWED_IMPORT_MODULE_PREFIXES: &[&str] = &["arkhe:hook/"];
pub use crate::wasm_runtime_common::WASI_DENY_PREFIXES as DENIED_IMPORT_MODULE_PREFIXES;
#[non_exhaustive]
#[derive(Debug, Default)]
pub struct HookStoreData {
pub capabilities: BTreeSet<CapToken>,
pub initial_fuel: u64,
pub scratchpad: BTreeMap<Vec<u8>, Vec<u8>>,
pub extra: Option<super::ExtraBytesBuilder>,
}
impl HookStoreData {
pub fn with_capabilities<I: IntoIterator<Item = CapToken>>(caps: I) -> Self {
Self {
capabilities: caps.into_iter().collect(),
initial_fuel: 0,
scratchpad: BTreeMap::new(),
extra: None,
}
}
pub fn with_initial_fuel(mut self, initial_fuel: u64) -> Self {
self.initial_fuel = initial_fuel;
self
}
pub fn with_scratchpad<I, K, V>(mut self, entries: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: Into<Vec<u8>>,
V: Into<Vec<u8>>,
{
self.scratchpad = entries
.into_iter()
.map(|(k, v)| (k.into(), v.into()))
.collect();
self
}
pub fn with_extra(mut self, extra: super::ExtraBytesBuilder) -> Self {
self.extra = Some(extra);
self
}
pub fn take_extra(&mut self) -> Option<super::ExtraBytesBuilder> {
self.extra.take()
}
}
pub struct CapabilityLinker {
inner: Linker<HookStoreData>,
}
impl crate::wasm_runtime_common::sealed_impl::Sealed for CapabilityLinker {}
impl crate::wasm_runtime_common::SealedHostImport for CapabilityLinker {}
impl std::fmt::Debug for CapabilityLinker {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CapabilityLinker")
.field("allowed_prefixes", &ALLOWED_IMPORT_MODULE_PREFIXES)
.field("denied_prefixes", &DENIED_IMPORT_MODULE_PREFIXES)
.finish_non_exhaustive()
}
}
impl CapabilityLinker {
pub fn deny_by_default(engine: &Engine) -> Result<Self, HookHostError> {
let mut linker = Linker::<HookStoreData>::new(engine);
linker
.func_wrap(
"arkhe:hook/state",
"read",
|mut caller: Caller<'_, HookStoreData>,
key_ptr: i32,
key_len: i32,
val_ptr_out: i32,
val_buf_len: i32|
-> Result<i32, wasmtime::Error> {
if !caller.data().capabilities.contains(&CapToken::StateRead) {
return Err(wasmtime::Error::msg(
"arkhe:hook/state.read called without StateRead capability",
));
}
if val_buf_len < 0 {
return Err(wasmtime::Error::msg(format!(
"OOB: negative val_buf_len ({val_buf_len})"
)));
}
let key = read_caller_memory(&mut caller, key_ptr, key_len)?;
let value: Option<Vec<u8>> = caller.data().scratchpad.get(&key).cloned();
match value {
None => Ok(-1),
Some(v) => {
if v.len() as u64 > val_buf_len as u64 {
return Ok(-2);
}
write_caller_memory(&mut caller, val_ptr_out, &v)?;
Ok(v.len() as i32)
}
}
},
)
.map_err(|e| HookHostError::LinkerSetupFailed {
reason: format!("arkhe:hook/state.read: {e}"),
})?;
linker
.func_wrap(
"arkhe:hook/state",
"write",
|mut caller: Caller<'_, HookStoreData>,
key_ptr: i32,
key_len: i32,
val_ptr: i32,
val_len: i32|
-> Result<(), wasmtime::Error> {
if !caller.data().capabilities.contains(&CapToken::StateWrite) {
return Err(wasmtime::Error::msg(
"arkhe:hook/state.write called without StateWrite capability",
));
}
let key = read_caller_memory(&mut caller, key_ptr, key_len)?;
let val = read_caller_memory(&mut caller, val_ptr, val_len)?;
caller.data_mut().scratchpad.insert(key, val);
Ok(())
},
)
.map_err(|e| HookHostError::LinkerSetupFailed {
reason: format!("arkhe:hook/state.write: {e}"),
})?;
linker
.func_wrap(
"arkhe:hook/emit",
"extra_bytes",
|mut caller: Caller<'_, HookStoreData>,
ptr: i32,
len: i32|
-> Result<(), wasmtime::Error> {
if !caller
.data()
.capabilities
.contains(&CapToken::EmitExtraBytes)
{
return Err(wasmtime::Error::msg(
"arkhe:hook/emit.extra_bytes called without EmitExtraBytes capability",
));
}
let bytes = read_caller_memory(&mut caller, ptr, len)?;
let extra = caller.data_mut().extra.as_mut().ok_or_else(|| {
wasmtime::Error::msg(
"arkhe:hook/emit.extra_bytes called without an `extra` builder seeded in HookStoreData",
)
})?;
extra.append(&bytes);
Ok(())
},
)
.map_err(|e| HookHostError::LinkerSetupFailed {
reason: format!("arkhe:hook/emit.extra_bytes: {e}"),
})?;
linker
.func_wrap(
"arkhe:hook/fuel",
"consumed",
|caller: Caller<'_, HookStoreData>| -> Result<i64, wasmtime::Error> {
if !caller.data().capabilities.contains(&CapToken::FuelConsumed) {
return Err(wasmtime::Error::msg(
"arkhe:hook/fuel.consumed called without FuelConsumed capability",
));
}
let initial = caller.data().initial_fuel;
let remaining = caller.get_fuel().unwrap_or(0);
let consumed = initial.saturating_sub(remaining);
Ok(i64::try_from(consumed).unwrap_or(i64::MAX))
},
)
.map_err(|e| HookHostError::LinkerSetupFailed {
reason: format!("arkhe:hook/fuel.consumed: {e}"),
})?;
Ok(Self { inner: linker })
}
pub fn linker(&self) -> &Linker<HookStoreData> {
&self.inner
}
}
pub fn scan_imports(engine: &Engine, bytes: &Bytes) -> Result<Module, HookHostError> {
scan_module_imports(
engine,
bytes,
ALLOWED_IMPORT_MODULE_PREFIXES,
DENIED_IMPORT_MODULE_PREFIXES,
"only `arkhe:hook/*` permitted",
)
.map_err(|e| match e {
ScanImportsError::ParseFailed { reason } => HookHostError::ModuleParseFailed { reason },
ScanImportsError::ImportRejected { name, reason } => {
HookHostError::ImportRejected { name, reason }
}
})
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
use crate::hook_host::wasmtime_host::WasmtimeEngineConfig;
use wasmtime::Store;
fn engine() -> Engine {
let cfg = WasmtimeEngineConfig::deterministic_replay();
Engine::new(&cfg.to_config()).expect("default engine builds")
}
fn wat_to_bytes(wat: &str) -> Bytes {
Bytes::from(wat::parse_str(wat).expect("valid wat"))
}
#[test]
fn linker_deny_by_default_constructs() {
let _ = CapabilityLinker::deny_by_default(&engine()).expect("default linker constructs");
}
#[test]
fn scan_accepts_module_with_only_arkhe_hook_state_read() {
let bytes = wat_to_bytes(
r#"(module
(import "arkhe:hook/state" "read"
(func (param i32 i32) (result i32))))"#,
);
let _module = scan_imports(&engine(), &bytes).expect("allowed import passes scan");
}
#[test]
fn scan_accepts_module_with_no_imports() {
let bytes = wat_to_bytes(r#"(module (func (export "noop")))"#);
let _ = scan_imports(&engine(), &bytes).expect("zero-import module passes");
}
#[test]
fn scan_rejects_wasi_random() {
let bytes = wat_to_bytes(
r#"(module
(import "wasi:random/random" "get-random-u64"
(func (result i64))))"#,
);
let err = scan_imports(&engine(), &bytes).expect_err("wasi:random must reject");
let msg = format!("{err}");
assert!(msg.contains("wasi:random"), "msg = {msg}");
assert!(msg.contains("denied namespace"), "msg = {msg}");
}
#[test]
fn scan_rejects_wasi_clocks() {
let bytes = wat_to_bytes(
r#"(module
(import "wasi:clocks/wall-clock" "now"
(func (result i64))))"#,
);
let err = scan_imports(&engine(), &bytes).expect_err("wasi:clocks must reject");
assert!(format!("{err}").contains("wasi:clocks"));
}
#[test]
fn scan_rejects_wasi_filesystem() {
let bytes = wat_to_bytes(
r#"(module
(import "wasi:filesystem/types" "open-at"
(func (param i32 i32 i32) (result i32))))"#,
);
let err = scan_imports(&engine(), &bytes).expect_err("wasi:filesystem must reject");
assert!(format!("{err}").contains("wasi:filesystem"));
}
#[test]
fn scan_rejects_wasi_sockets() {
let bytes = wat_to_bytes(
r#"(module
(import "wasi:sockets/tcp" "connect"
(func (param i32) (result i32))))"#,
);
let err = scan_imports(&engine(), &bytes).expect_err("wasi:sockets must reject");
assert!(format!("{err}").contains("wasi:sockets"));
}
#[test]
fn scan_rejects_wasi_io() {
let bytes = wat_to_bytes(
r#"(module
(import "wasi:io/streams" "write"
(func (param i32 i32 i32) (result i32))))"#,
);
let err = scan_imports(&engine(), &bytes).expect_err("wasi:io must reject");
assert!(format!("{err}").contains("wasi:io"));
}
#[test]
fn scan_rejects_wasi_cli() {
let bytes = wat_to_bytes(
r#"(module
(import "wasi:cli/environment" "get-environment"
(func (result i32))))"#,
);
let err = scan_imports(&engine(), &bytes).expect_err("wasi:cli must reject");
assert!(format!("{err}").contains("wasi:cli"));
}
#[test]
fn scan_rejects_wasi_http() {
let bytes = wat_to_bytes(
r#"(module
(import "wasi:http/outgoing-handler" "handle"
(func (param i32) (result i32))))"#,
);
let err = scan_imports(&engine(), &bytes).expect_err("wasi:http must reject");
assert!(format!("{err}").contains("wasi:http"));
}
#[test]
fn scan_rejects_unknown_namespace_with_allow_list_message() {
let bytes = wat_to_bytes(
r#"(module
(import "evil:network/exfil" "send"
(func (param i32 i32))))"#,
);
let err = scan_imports(&engine(), &bytes).expect_err("unknown ns must reject");
let msg = format!("{err}");
assert!(msg.contains("not in allow-list"), "msg = {msg}");
assert!(msg.contains("evil:network"), "msg = {msg}");
}
#[test]
fn scan_rejects_arkhe_observer_imports_in_hook_context() {
let bytes = wat_to_bytes(
r#"(module
(import "arkhe:observer/pg" "write"
(func (param i32 i32))))"#,
);
let err = scan_imports(&engine(), &bytes)
.expect_err("arkhe:observer/* must reject in hook context");
let msg = format!("{err}");
assert!(
msg.contains("not in allow-list (only `arkhe:hook/*` permitted)"),
"expected catch-all rejection, got: {msg}"
);
}
#[test]
fn scan_rejects_invalid_wasm_bytes() {
let bytes = Bytes::from_static(&[0x00, 0x61, 0x73, 0x6d]);
let err = scan_imports(&engine(), &bytes).expect_err("invalid bytes must reject");
assert!(matches!(err, HookHostError::ModuleParseFailed { .. }));
}
const TEST_FUEL: u64 = 1_000_000;
#[test]
fn state_read_traps_with_capability_denied_when_missing() {
let engine = engine();
let cap_linker = CapabilityLinker::deny_by_default(&engine).unwrap();
let bytes = wat_to_bytes(
r#"(module
(import "arkhe:hook/state" "read"
(func $r (param i32 i32 i32 i32) (result i32)))
(memory (export "memory") 1)
(func (export "go") (result i32)
i32.const 0
i32.const 0
i32.const 0
i32.const 0
call $r))"#,
);
let module = scan_imports(&engine, &bytes).unwrap();
let mut store = Store::new(&engine, HookStoreData::default());
store.set_fuel(TEST_FUEL).expect("seed fuel");
let inst = cap_linker
.linker()
.instantiate(&mut store, &module)
.unwrap();
let go = inst
.get_typed_func::<(), i32>(&mut store, "go")
.expect("go export");
let err = go.call(&mut store, ()).expect_err("trap expected");
let msg = format!("{err:?}");
assert!(msg.contains("StateRead capability"), "msg = {msg}");
}
#[test]
fn state_read_returns_minus_one_when_key_not_found() {
let engine = engine();
let cap_linker = CapabilityLinker::deny_by_default(&engine).unwrap();
let bytes = wat_to_bytes(
r#"(module
(import "arkhe:hook/state" "read"
(func $r (param i32 i32 i32 i32) (result i32)))
(memory (export "memory") 1)
(data (i32.const 0) "k")
(func (export "go") (result i32)
i32.const 0
i32.const 1
i32.const 8
i32.const 64
call $r))"#,
);
let module = scan_imports(&engine, &bytes).unwrap();
let store_data = HookStoreData::with_capabilities([CapToken::StateRead]);
let mut store = Store::new(&engine, store_data);
store.set_fuel(TEST_FUEL).expect("seed fuel");
let inst = cap_linker
.linker()
.instantiate(&mut store, &module)
.unwrap();
let go = inst
.get_typed_func::<(), i32>(&mut store, "go")
.expect("go export");
let result = go.call(&mut store, ()).expect("call should succeed");
assert_eq!(result, -1, "expected -1 for not-found, got {result}");
}
#[test]
fn state_read_returns_seeded_value_length_when_key_found() {
let engine = engine();
let cap_linker = CapabilityLinker::deny_by_default(&engine).unwrap();
let bytes = wat_to_bytes(
r#"(module
(import "arkhe:hook/state" "read"
(func $r (param i32 i32 i32 i32) (result i32)))
(memory (export "memory") 1)
(data (i32.const 0) "k")
(func (export "go") (result i32)
i32.const 0
i32.const 1
i32.const 8
i32.const 64
call $r))"#,
);
let module = scan_imports(&engine, &bytes).unwrap();
let store_data = HookStoreData::with_capabilities([CapToken::StateRead])
.with_scratchpad([(b"k" as &[u8], b"value-bytes-here" as &[u8])]);
let mut store = Store::new(&engine, store_data);
store.set_fuel(TEST_FUEL).expect("seed fuel");
let inst = cap_linker
.linker()
.instantiate(&mut store, &module)
.unwrap();
let go = inst
.get_typed_func::<(), i32>(&mut store, "go")
.expect("go export");
let bytes_copied = go.call(&mut store, ()).expect("call should succeed");
assert_eq!(bytes_copied, b"value-bytes-here".len() as i32);
}
#[test]
fn state_read_returns_minus_two_when_buffer_too_small() {
let engine = engine();
let cap_linker = CapabilityLinker::deny_by_default(&engine).unwrap();
let bytes = wat_to_bytes(
r#"(module
(import "arkhe:hook/state" "read"
(func $r (param i32 i32 i32 i32) (result i32)))
(memory (export "memory") 1)
(data (i32.const 0) "k")
(func (export "go") (result i32)
i32.const 0
i32.const 1
i32.const 8
i32.const 2
call $r))"#,
);
let module = scan_imports(&engine, &bytes).unwrap();
let store_data = HookStoreData::with_capabilities([CapToken::StateRead])
.with_scratchpad([(b"k" as &[u8], b"value-bytes-here" as &[u8])]);
let mut store = Store::new(&engine, store_data);
store.set_fuel(TEST_FUEL).expect("seed fuel");
let inst = cap_linker
.linker()
.instantiate(&mut store, &module)
.unwrap();
let go = inst
.get_typed_func::<(), i32>(&mut store, "go")
.expect("go export");
let result = go.call(&mut store, ()).expect("call should succeed");
assert_eq!(result, -2, "expected -2 for buffer-too-small, got {result}");
}
#[test]
fn link_time_deny_by_default_rejects_unknown_arkhe_hook_name() {
let engine = engine();
let cap_linker = CapabilityLinker::deny_by_default(&engine).unwrap();
let bytes = wat_to_bytes(
r#"(module
(import "arkhe:hook/state" "unknown_fn"
(func)))"#,
);
let module = scan_imports(&engine, &bytes).expect("scan accepts allowed namespace");
let mut store = Store::new(&engine, HookStoreData::default());
let err = cap_linker
.linker()
.instantiate(&mut store, &module)
.expect_err("unknown name must fail at link-time");
let msg = format!("{err:?}");
assert!(
msg.contains("unknown") || msg.contains("import") || msg.contains("not"),
"expected link-time rejection, got: {msg}"
);
}
#[test]
fn debug_format_lists_prefixes() {
let cl = CapabilityLinker::deny_by_default(&engine()).unwrap();
let s = format!("{cl:?}");
assert!(s.contains("arkhe:hook/"));
assert!(s.contains("wasi:random"));
}
#[test]
fn hook_store_data_default_has_no_capabilities() {
let d = HookStoreData::default();
assert!(d.capabilities.is_empty());
}
#[test]
fn hook_store_data_with_capabilities_round_trips() {
let d = HookStoreData::with_capabilities([CapToken::StateRead, CapToken::FuelConsumed]);
assert!(d.capabilities.contains(&CapToken::StateRead));
assert!(d.capabilities.contains(&CapToken::FuelConsumed));
assert_eq!(d.capabilities.len(), 2);
}
#[test]
fn wit_boundary_check_routes_lookalike_to_allow_list_message() {
let bytes = wat_to_bytes(
r#"(module
(import "wasi:randomly-pure" "noop"
(func)))"#,
);
let err = scan_imports(&engine(), &bytes).expect_err("lookalike must reject");
let msg = format!("{err}");
assert!(msg.contains("not in allow-list"), "msg = {msg}");
assert!(!msg.contains("denied namespace"), "msg = {msg}");
assert!(msg.contains("wasi:randomly-pure"), "msg = {msg}");
}
#[test]
fn wit_boundary_check_admits_exact_prefix_match() {
let bytes = wat_to_bytes(
r#"(module
(import "wasi:random" "f"
(func)))"#,
);
let err = scan_imports(&engine(), &bytes).expect_err("exact prefix must reject");
let msg = format!("{err}");
assert!(msg.contains("denied namespace"), "msg = {msg}");
assert!(msg.contains("wasi:random"), "msg = {msg}");
}
#[test]
fn wit_boundary_check_admits_at_version_suffix() {
let bytes = wat_to_bytes(
r#"(module
(import "wasi:clocks@0.2.0/wall-clock" "now"
(func (result i64))))"#,
);
let err = scan_imports(&engine(), &bytes).expect_err("@-suffix must reject");
let msg = format!("{err}");
assert!(msg.contains("denied namespace"), "msg = {msg}");
assert!(msg.contains("wasi:clocks"), "msg = {msg}");
}
#[test]
fn state_write_traps_with_capability_denied_when_missing() {
let engine = engine();
let cap_linker = CapabilityLinker::deny_by_default(&engine).unwrap();
let bytes = wat_to_bytes(
r#"(module
(import "arkhe:hook/state" "write"
(func $w (param i32 i32 i32 i32)))
(func (export "go")
i32.const 0
i32.const 0
i32.const 0
i32.const 0
call $w))"#,
);
let module = scan_imports(&engine, &bytes).unwrap();
let mut store = Store::new(&engine, HookStoreData::default());
store.set_fuel(TEST_FUEL).expect("seed fuel");
let inst = cap_linker
.linker()
.instantiate(&mut store, &module)
.unwrap();
let go = inst
.get_typed_func::<(), ()>(&mut store, "go")
.expect("go export");
let err = go.call(&mut store, ()).expect_err("trap expected");
let msg = format!("{err:?}");
assert!(msg.contains("StateWrite capability"), "msg = {msg}");
}
#[test]
fn state_write_inserts_into_scratchpad() {
let engine = engine();
let cap_linker = CapabilityLinker::deny_by_default(&engine).unwrap();
let bytes = wat_to_bytes(
r#"(module
(import "arkhe:hook/state" "write"
(func $w (param i32 i32 i32 i32)))
(memory (export "memory") 1)
(data (i32.const 0) "kkvvvv")
(func (export "go")
i32.const 0
i32.const 2
i32.const 2
i32.const 4
call $w))"#,
);
let module = scan_imports(&engine, &bytes).unwrap();
let store_data = HookStoreData::with_capabilities([CapToken::StateWrite]);
let mut store = Store::new(&engine, store_data);
store.set_fuel(TEST_FUEL).expect("seed fuel");
let inst = cap_linker
.linker()
.instantiate(&mut store, &module)
.unwrap();
let go = inst
.get_typed_func::<(), ()>(&mut store, "go")
.expect("go export");
go.call(&mut store, ()).expect("call should succeed");
let value = store.data().scratchpad.get(b"kk" as &[u8]).cloned();
assert_eq!(value, Some(b"vvvv".to_vec()));
}
#[test]
fn state_round_trip_write_then_read() {
let engine = engine();
let cap_linker = CapabilityLinker::deny_by_default(&engine).unwrap();
let bytes = wat_to_bytes(
r#"(module
(import "arkhe:hook/state" "write"
(func $w (param i32 i32 i32 i32)))
(import "arkhe:hook/state" "read"
(func $r (param i32 i32 i32 i32) (result i32)))
(memory (export "memory") 1)
(data (i32.const 0) "khello")
(func (export "go") (result i32)
i32.const 0
i32.const 1
i32.const 1
i32.const 5
call $w
i32.const 0
i32.const 1
i32.const 16
i32.const 32
call $r))"#,
);
let module = scan_imports(&engine, &bytes).unwrap();
let store_data =
HookStoreData::with_capabilities([CapToken::StateRead, CapToken::StateWrite]);
let mut store = Store::new(&engine, store_data);
store.set_fuel(TEST_FUEL).expect("seed fuel");
let inst = cap_linker
.linker()
.instantiate(&mut store, &module)
.unwrap();
let go = inst
.get_typed_func::<(), i32>(&mut store, "go")
.expect("go export");
let bytes_copied = go.call(&mut store, ()).expect("call should succeed");
assert_eq!(bytes_copied, 5, "state.read should report 5 bytes copied");
let memory = inst.get_memory(&mut store, "memory").expect("memory");
let mut out = [0u8; 5];
memory
.read(&store, 16, &mut out)
.expect("read output buffer");
assert_eq!(&out, b"hello");
}
#[test]
fn emit_extra_bytes_traps_with_capability_denied_when_missing() {
let engine = engine();
let cap_linker = CapabilityLinker::deny_by_default(&engine).unwrap();
let bytes = wat_to_bytes(
r#"(module
(import "arkhe:hook/emit" "extra_bytes"
(func $e (param i32 i32)))
(func (export "go")
i32.const 0
i32.const 0
call $e))"#,
);
let module = scan_imports(&engine, &bytes).unwrap();
let mut store = Store::new(&engine, HookStoreData::default());
store.set_fuel(TEST_FUEL).expect("seed fuel");
let inst = cap_linker
.linker()
.instantiate(&mut store, &module)
.unwrap();
let go = inst
.get_typed_func::<(), ()>(&mut store, "go")
.expect("go export");
let err = go.call(&mut store, ()).expect_err("trap expected");
let msg = format!("{err:?}");
assert!(msg.contains("EmitExtraBytes capability"), "msg = {msg}");
}
#[test]
fn emit_extra_bytes_appends_to_seeded_builder() {
let engine = engine();
let cap_linker = CapabilityLinker::deny_by_default(&engine).unwrap();
let bytes = wat_to_bytes(
r#"(module
(import "arkhe:hook/emit" "extra_bytes"
(func $e (param i32 i32)))
(memory (export "memory") 1)
(data (i32.const 0) "ABCD")
(func (export "go")
i32.const 0
i32.const 4
call $e))"#,
);
let module = scan_imports(&engine, &bytes).unwrap();
let store_data = HookStoreData::with_capabilities([CapToken::EmitExtraBytes])
.with_extra(super::super::ExtraBytesBuilder::new());
let mut store = Store::new(&engine, store_data);
store.set_fuel(TEST_FUEL).expect("seed fuel");
let inst = cap_linker
.linker()
.instantiate(&mut store, &module)
.unwrap();
let go = inst
.get_typed_func::<(), ()>(&mut store, "go")
.expect("go export");
go.call(&mut store, ()).expect("call should succeed");
let extra = store.data_mut().take_extra().expect("extra was seeded");
let frozen = extra.freeze();
assert_eq!(&frozen[..], b"ABCD");
}
#[test]
fn emit_extra_bytes_traps_when_extra_not_seeded() {
let engine = engine();
let cap_linker = CapabilityLinker::deny_by_default(&engine).unwrap();
let bytes = wat_to_bytes(
r#"(module
(import "arkhe:hook/emit" "extra_bytes"
(func $e (param i32 i32)))
(memory (export "memory") 1)
(data (i32.const 0) "AB")
(func (export "go")
i32.const 0
i32.const 2
call $e))"#,
);
let module = scan_imports(&engine, &bytes).unwrap();
let store_data = HookStoreData::with_capabilities([CapToken::EmitExtraBytes]);
let mut store = Store::new(&engine, store_data);
store.set_fuel(TEST_FUEL).expect("seed fuel");
let inst = cap_linker
.linker()
.instantiate(&mut store, &module)
.unwrap();
let go = inst
.get_typed_func::<(), ()>(&mut store, "go")
.expect("go export");
let err = go.call(&mut store, ()).expect_err("trap expected");
let msg = format!("{err:?}");
assert!(
msg.contains("without an `extra` builder seeded"),
"msg = {msg}"
);
}
#[test]
fn fuel_consumed_traps_with_capability_denied_when_missing() {
let engine = engine();
let cap_linker = CapabilityLinker::deny_by_default(&engine).unwrap();
let bytes = wat_to_bytes(
r#"(module
(import "arkhe:hook/fuel" "consumed"
(func $f (result i64)))
(func (export "go") (result i64) call $f))"#,
);
let module = scan_imports(&engine, &bytes).unwrap();
let mut store = Store::new(&engine, HookStoreData::default());
store.set_fuel(TEST_FUEL).expect("seed fuel");
let inst = cap_linker
.linker()
.instantiate(&mut store, &module)
.unwrap();
let go = inst
.get_typed_func::<(), i64>(&mut store, "go")
.expect("go export");
let err = go.call(&mut store, ()).expect_err("trap expected");
let msg = format!("{err:?}");
assert!(msg.contains("FuelConsumed capability"), "msg = {msg}");
}
#[test]
fn fuel_consumed_returns_zero_at_invocation_start() {
let engine = engine();
let cap_linker = CapabilityLinker::deny_by_default(&engine).unwrap();
let bytes = wat_to_bytes(
r#"(module
(import "arkhe:hook/fuel" "consumed"
(func $f (result i64)))
(func (export "go") (result i64) call $f))"#,
);
let module = scan_imports(&engine, &bytes).unwrap();
let store_data =
HookStoreData::with_capabilities([CapToken::FuelConsumed]).with_initial_fuel(TEST_FUEL);
let mut store = Store::new(&engine, store_data);
store.set_fuel(TEST_FUEL).expect("seed fuel");
let inst = cap_linker
.linker()
.instantiate(&mut store, &module)
.unwrap();
let go = inst
.get_typed_func::<(), i64>(&mut store, "go")
.expect("go export");
let consumed = go.call(&mut store, ()).expect("call should succeed");
assert!(consumed >= 0, "consumed = {consumed}");
assert!(
(consumed as u64) < TEST_FUEL / 100,
"expected minimal consumption, got {consumed} of {TEST_FUEL}"
);
}
#[test]
fn fuel_consumed_grows_with_wasm_work() {
let engine = engine();
let cap_linker = CapabilityLinker::deny_by_default(&engine).unwrap();
let bytes = wat_to_bytes(
r#"(module
(import "arkhe:hook/fuel" "consumed"
(func $f (result i64)))
(func (export "go") (result i64)
(local $i i32)
(local.set $i (i32.const 1000))
(loop $continue
(local.set $i (i32.sub (local.get $i) (i32.const 1)))
(br_if $continue (i32.gt_s (local.get $i) (i32.const 0))))
call $f))"#,
);
let module = scan_imports(&engine, &bytes).unwrap();
let store_data =
HookStoreData::with_capabilities([CapToken::FuelConsumed]).with_initial_fuel(TEST_FUEL);
let mut store = Store::new(&engine, store_data);
store.set_fuel(TEST_FUEL).expect("seed fuel");
let inst = cap_linker
.linker()
.instantiate(&mut store, &module)
.unwrap();
let go = inst
.get_typed_func::<(), i64>(&mut store, "go")
.expect("go export");
let consumed = go.call(&mut store, ()).expect("call should succeed");
assert!(
consumed > 100,
"expected loop-driven consumption, got {consumed}"
);
}
#[test]
fn fuel_consumed_handles_zero_initial_gracefully() {
let engine = engine();
let cap_linker = CapabilityLinker::deny_by_default(&engine).unwrap();
let bytes = wat_to_bytes(
r#"(module
(import "arkhe:hook/fuel" "consumed"
(func $f (result i64)))
(func (export "go") (result i64) call $f))"#,
);
let module = scan_imports(&engine, &bytes).unwrap();
let store_data = HookStoreData::with_capabilities([CapToken::FuelConsumed]);
let mut store = Store::new(&engine, store_data);
store.set_fuel(TEST_FUEL).expect("seed fuel");
let inst = cap_linker
.linker()
.instantiate(&mut store, &module)
.unwrap();
let go = inst
.get_typed_func::<(), i64>(&mut store, "go")
.expect("go export");
let consumed = go.call(&mut store, ()).expect("call should succeed");
assert_eq!(consumed, 0, "consumed = {consumed} should saturate to 0");
}
#[test]
fn hook_store_data_with_initial_fuel_round_trips() {
let d =
HookStoreData::with_capabilities([CapToken::FuelConsumed]).with_initial_fuel(42_000);
assert_eq!(d.initial_fuel, 42_000);
assert!(d.capabilities.contains(&CapToken::FuelConsumed));
}
#[test]
fn wasm_traps_when_fuel_exhausted() {
let engine = engine();
let cap_linker = CapabilityLinker::deny_by_default(&engine).unwrap();
let bytes = wat_to_bytes(
r#"(module
(func (export "go")
(loop $forever (br $forever))))"#,
);
let module = scan_imports(&engine, &bytes).unwrap();
let mut store = Store::new(&engine, HookStoreData::default());
store.set_fuel(1_000).expect("seed fuel");
let inst = cap_linker
.linker()
.instantiate(&mut store, &module)
.unwrap();
let go = inst
.get_typed_func::<(), ()>(&mut store, "go")
.expect("go export");
let err = go.call(&mut store, ()).expect_err("fuel must exhaust");
let msg = format!("{err:?}");
assert!(
msg.contains("fuel") || msg.contains("Fuel"),
"expected fuel-exhaustion trap, got: {msg}"
);
}
fn wat_state_read_with_memory(key_ptr: i32, key_len: i32) -> Bytes {
wat_state_read_full(key_ptr, key_len, 0, 0)
}
fn wat_state_read_full(
key_ptr: i32,
key_len: i32,
val_ptr_out: i32,
val_buf_len: i32,
) -> Bytes {
let wat = format!(
r#"(module
(import "arkhe:hook/state" "read"
(func $r (param i32 i32 i32 i32) (result i32)))
(memory (export "memory") 1)
(func (export "go") (result i32)
i32.const {key_ptr}
i32.const {key_len}
i32.const {val_ptr_out}
i32.const {val_buf_len}
call $r))"#
);
Bytes::from(wat::parse_str(&wat).expect("valid wat"))
}
fn wat_state_write_with_memory(
key_ptr: i32,
key_len: i32,
val_ptr: i32,
val_len: i32,
) -> Bytes {
let wat = format!(
r#"(module
(import "arkhe:hook/state" "write"
(func $w (param i32 i32 i32 i32)))
(memory (export "memory") 1)
(func (export "go")
i32.const {key_ptr}
i32.const {key_len}
i32.const {val_ptr}
i32.const {val_len}
call $w))"#
);
Bytes::from(wat::parse_str(&wat).expect("valid wat"))
}
fn wat_emit_with_memory(ptr: i32, len: i32) -> Bytes {
let wat = format!(
r#"(module
(import "arkhe:hook/emit" "extra_bytes"
(func $e (param i32 i32)))
(memory (export "memory") 1)
(func (export "go")
i32.const {ptr}
i32.const {len}
call $e))"#
);
Bytes::from(wat::parse_str(&wat).expect("valid wat"))
}
fn run_export<R: wasmtime::WasmResults>(
bytes: Bytes,
cap: CapToken,
) -> Result<R, wasmtime::Error> {
let engine = engine();
let cap_linker = CapabilityLinker::deny_by_default(&engine).unwrap();
let module = scan_imports(&engine, &bytes).unwrap();
let store_data = HookStoreData::with_capabilities([cap]);
let mut store = Store::new(&engine, store_data);
store.set_fuel(TEST_FUEL).unwrap();
let inst = cap_linker
.linker()
.instantiate(&mut store, &module)
.unwrap();
let go = inst
.get_typed_func::<(), R>(&mut store, "go")
.expect("go export");
go.call(&mut store, ())
}
#[test]
fn state_read_traps_on_oob_ptr_plus_len() {
let bytes = wat_state_read_with_memory(65_000, 1_000);
let err = run_export::<i32>(bytes, CapToken::StateRead).expect_err("OOB expected");
let msg = format!("{err:?}");
assert!(msg.contains("OOB"), "msg = {msg}");
assert!(msg.contains("exceeds memory size"), "msg = {msg}");
}
#[test]
fn state_read_traps_on_negative_len() {
let bytes = wat_state_read_with_memory(0, -1);
let err = run_export::<i32>(bytes, CapToken::StateRead).expect_err("negative len trap");
let msg = format!("{err:?}");
assert!(
msg.contains("OOB") && msg.contains("negative length"),
"msg = {msg}"
);
}
#[test]
fn state_read_traps_on_negative_ptr() {
let bytes = wat_state_read_with_memory(-1, 1);
let err = run_export::<i32>(bytes, CapToken::StateRead).expect_err("negative ptr trap");
let msg = format!("{err:?}");
assert!(
msg.contains("OOB") && msg.contains("negative pointer"),
"msg = {msg}"
);
}
#[test]
fn state_read_traps_on_huge_ptr() {
let bytes = wat_state_read_with_memory(i32::MAX, 1);
let err = run_export::<i32>(bytes, CapToken::StateRead).expect_err("huge ptr trap");
let msg = format!("{err:?}");
assert!(msg.contains("OOB"), "msg = {msg}");
}
#[test]
fn state_read_zero_key_returns_minus_one_in_empty_scratchpad() {
let bytes = wat_state_read_with_memory(0, 0);
let result = run_export::<i32>(bytes, CapToken::StateRead)
.expect("zero-len key looks up empty scratchpad");
assert_eq!(result, -1, "expected -1 (not-found), got {result}");
}
#[test]
fn state_write_traps_on_key_oob() {
let bytes = wat_state_write_with_memory(65_000, 1_000, 0, 0);
let err = run_export::<()>(bytes, CapToken::StateWrite).expect_err("key OOB expected");
let msg = format!("{err:?}");
assert!(msg.contains("OOB"), "msg = {msg}");
}
#[test]
fn state_write_traps_on_val_oob() {
let bytes = wat_state_write_with_memory(0, 0, 65_000, 1_000);
let err = run_export::<()>(bytes, CapToken::StateWrite).expect_err("val OOB expected");
let msg = format!("{err:?}");
assert!(msg.contains("OOB"), "msg = {msg}");
}
#[test]
fn emit_extra_bytes_traps_on_oob() {
let bytes = wat_emit_with_memory(65_000, 1_000);
let err = run_export::<()>(bytes, CapToken::EmitExtraBytes).expect_err("OOB expected");
let msg = format!("{err:?}");
assert!(msg.contains("OOB"), "msg = {msg}");
}
#[test]
fn read_caller_memory_traps_when_module_has_no_memory_export() {
let bytes = Bytes::from(
wat::parse_str(
r#"(module
(import "arkhe:hook/state" "read"
(func $r (param i32 i32 i32 i32) (result i32)))
(func (export "go") (result i32)
i32.const 0
i32.const 4
i32.const 0
i32.const 0
call $r))"#,
)
.expect("valid wat"),
);
let err = run_export::<i32>(bytes, CapToken::StateRead)
.expect_err("no-memory module must trap on len>0");
let msg = format!("{err:?}");
assert!(msg.contains("does not export"), "msg = {msg}");
}
}