#![cfg(feature = "wasm")]
use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;
use ed25519_dalek::SigningKey;
use ed25519_dalek::VerifyingKey;
use rill_runtime::{
InvokeErrorKind, InvokeHandler, LoadedHandlerPack, TrustStore, WasmInvokeHandler,
build_signed_handler_pack, load_handler_pack,
};
use rill_runtime_protocol::{
HANDLER_API_VERSION, HANDLER_PACKAGE_FORMAT_VERSION, HandlerPackManifest,
};
use sha2::{Digest, Sha256};
static WASM_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn wasm_test_guard() -> std::sync::MutexGuard<'static, ()> {
WASM_TEST_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn echo_handler_component() -> Option<PathBuf> {
if let Ok(path) = std::env::var("ECHO_HANDLER_WASM") {
let path = PathBuf::from(path);
if path.exists() {
return Some(path);
}
}
let workspace_target =
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/echo-handler.wasm");
if workspace_target.exists() {
return Some(workspace_target);
}
let local_component = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../handlers/echo-handler/echo-handler.wasm");
if local_component.exists() {
return Some(local_component);
}
None
}
fn build_echo_pack(module: &[u8], signing: &SigningKey) -> Vec<u8> {
let manifest = HandlerPackManifest {
format_version: HANDLER_PACKAGE_FORMAT_VERSION,
id: "rillml.echo.handler".into(),
version: env!("CARGO_PKG_VERSION").into(),
handler_api_version: HANDLER_API_VERSION,
min_runtime_version: env!("CARGO_PKG_VERSION").into(),
publisher_key_id: "wasm-test-key".into(),
capabilities: vec!["rillml.linearRegression.predict".into()],
module_sha256: hex::encode(Sha256::digest(module)),
module_size: module.len() as u64,
};
build_signed_handler_pack(&manifest, module, signing).unwrap()
}
fn load_echo_pack(
pack_bytes: &[u8],
verifying: &VerifyingKey,
) -> (LoadedHandlerPack, rill_runtime::HandlerPackInspection) {
let trust = TrustStore(BTreeMap::from([("wasm-test-key".into(), *verifying)]));
load_handler_pack(std::io::Cursor::new(pack_bytes), &trust).unwrap()
}
#[test]
fn echo_handler_invoke_returns_input() {
let _guard = wasm_test_guard();
let component = match echo_handler_component() {
Some(path) => fs::read(&path).unwrap(),
None => {
eprintln!("skipping: echo handler component not built (set ECHO_HANDLER_WASM)");
return;
}
};
let signing = SigningKey::from_bytes(&[7; 32]);
let pack_bytes = build_echo_pack(&component, &signing);
let (loaded, inspection) = load_echo_pack(&pack_bytes, &signing.verifying_key());
assert_eq!(inspection.id, "rillml.echo.handler");
assert!(inspection.signature_verified);
let model = serde_json::json!({"kind": "linearRegression", "weights": [0.5], "intercept": 0.0});
let handler = WasmInvokeHandler::new(&loaded, &model).unwrap();
let input = serde_json::json!({"features": [1.0, 2.0]});
let output = handler
.invoke("rillml.linearRegression.predict", &input)
.unwrap();
assert_eq!(output, input);
}
#[test]
fn echo_handler_rejects_unsupported_capability() {
let _guard = wasm_test_guard();
let component = match echo_handler_component() {
Some(path) => fs::read(&path).unwrap(),
None => {
eprintln!("skipping: echo handler component not built (set ECHO_HANDLER_WASM)");
return;
}
};
let signing = SigningKey::from_bytes(&[7; 32]);
let pack_bytes = build_echo_pack(&component, &signing);
let (loaded, _) = load_echo_pack(&pack_bytes, &signing.verifying_key());
let model = serde_json::json!({"kind": "linearRegression"});
let handler = WasmInvokeHandler::new(&loaded, &model).unwrap();
let result = handler.invoke("rillml.unknown.predict", &serde_json::json!({}));
assert!(result.is_err());
let error = result.unwrap_err();
assert!(
matches!(error.kind(), InvokeErrorKind::UnsupportedCapability),
"expected UnsupportedCapability, got: {:?}",
error.kind()
);
assert_eq!(error.stable_code(), "handlerInternalError");
assert!(!error.public_message().contains("UnsupportedCapability"));
assert!(!error.public_message().contains("unsupported"));
}
#[test]
fn echo_handler_metadata_mismatch_rejected() {
let _guard = wasm_test_guard();
let component = match echo_handler_component() {
Some(path) => fs::read(&path).unwrap(),
None => {
eprintln!("skipping: echo handler component not built (set ECHO_HANDLER_WASM)");
return;
}
};
let signing = SigningKey::from_bytes(&[7; 32]);
let manifest = HandlerPackManifest {
format_version: HANDLER_PACKAGE_FORMAT_VERSION,
id: "wrong.handler.id".into(), version: env!("CARGO_PKG_VERSION").into(),
handler_api_version: HANDLER_API_VERSION,
min_runtime_version: env!("CARGO_PKG_VERSION").into(),
publisher_key_id: "wasm-test-key".into(),
capabilities: vec!["rillml.linearRegression.predict".into()],
module_sha256: hex::encode(Sha256::digest(&component)),
module_size: component.len() as u64,
};
let pack_bytes = build_signed_handler_pack(&manifest, &component, &signing).unwrap();
let trust = TrustStore(BTreeMap::from([(
"wasm-test-key".into(),
signing.verifying_key(),
)]));
let (loaded, _) = load_handler_pack(std::io::Cursor::new(&pack_bytes), &trust).unwrap();
let model = serde_json::json!({});
let result = WasmInvokeHandler::new(&loaded, &model);
assert!(result.is_err());
let error = result.unwrap_err();
assert!(
matches!(error, rill_runtime::HandlerLoadError::MetadataMismatch(_)),
"expected MetadataMismatch, got: {error:?}"
);
}
#[test]
fn wasm_handler_rejects_oversized_output() {
let _guard = wasm_test_guard();
use rill_runtime::MAX_IO_BYTES;
assert_eq!(MAX_IO_BYTES, 1024 * 1024);
}
#[test]
fn wasm_handler_sandbox_limits_verified() {
let _guard = wasm_test_guard();
use rill_runtime::{
CONFIGURE_FUEL, EPOCH_DEADLINE, EPOCH_TICK_INTERVAL, INVOKE_FUEL, MAX_IO_BYTES,
MAX_MEMORY_BYTES, MAX_TABLE_ELEMENTS,
};
use std::time::Duration;
assert_eq!(CONFIGURE_FUEL, 10_000_000);
assert_eq!(INVOKE_FUEL, 100_000_000);
assert_eq!(MAX_MEMORY_BYTES, 64 * 1024 * 1024);
assert_eq!(MAX_TABLE_ELEMENTS, 10_000);
assert_eq!(MAX_IO_BYTES, 1024 * 1024);
assert_eq!(EPOCH_TICK_INTERVAL, Duration::from_secs(1));
assert_eq!(EPOCH_DEADLINE, 5);
}
fn malicious_handler_component() -> Option<PathBuf> {
if let Ok(path) = std::env::var("MALICIOUS_HANDLER_WASM") {
let path = PathBuf::from(path);
if path.exists() {
return Some(path);
}
}
let workspace_target =
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/test-malicious-handler.wasm");
if workspace_target.exists() {
return Some(workspace_target);
}
None
}
fn build_malicious_handler_pack(module: &[u8], signing: &SigningKey) -> Vec<u8> {
let manifest = HandlerPackManifest {
format_version: HANDLER_PACKAGE_FORMAT_VERSION,
id: "rillml.test.malicious".into(),
version: env!("CARGO_PKG_VERSION").into(),
handler_api_version: HANDLER_API_VERSION,
min_runtime_version: env!("CARGO_PKG_VERSION").into(),
publisher_key_id: "wasm-test-key".into(),
capabilities: vec!["rillml.linearRegression.predict".into()],
module_sha256: hex::encode(Sha256::digest(module)),
module_size: module.len() as u64,
};
build_signed_handler_pack(&manifest, module, signing).unwrap()
}
fn load_malicious_handler_pack(pack_bytes: &[u8], verifying: &VerifyingKey) -> LoadedHandlerPack {
let trust = TrustStore(BTreeMap::from([("wasm-test-key".into(), *verifying)]));
let (loaded, _) = load_handler_pack(std::io::Cursor::new(pack_bytes), &trust).unwrap();
loaded
}
fn prepare_malicious_handler() -> Option<(LoadedHandlerPack, SigningKey)> {
let component = match malicious_handler_component() {
Some(path) => fs::read(&path).unwrap(),
None => {
eprintln!(
"skipping: malicious handler component not built (set MALICIOUS_HANDLER_WASM)"
);
return None;
}
};
let signing = SigningKey::from_bytes(&[8; 32]);
let pack_bytes = build_malicious_handler_pack(&component, &signing);
let loaded = load_malicious_handler_pack(&pack_bytes, &signing.verifying_key());
Some((loaded, signing))
}
#[test]
fn wasm_handler_trap_returns_handler_trap_error() {
let _guard = wasm_test_guard();
let (loaded, _) = match prepare_malicious_handler() {
Some(v) => v,
None => return,
};
let model = serde_json::json!({"mode": "trap"});
let handler = WasmInvokeHandler::new(&loaded, &model).unwrap();
let result = handler.invoke("rillml.linearRegression.predict", &serde_json::json!({}));
assert!(result.is_err());
let error = result.unwrap_err();
assert!(
matches!(error.kind(), InvokeErrorKind::Trap),
"expected Trap, got: {:?}",
error.kind()
);
assert_eq!(error.stable_code(), "handlerTrap");
assert_eq!(error.public_message(), "handler trapped");
assert!(!error.public_message().contains("unreachable"));
assert!(!error.stable_code().contains("unreachable"));
}
#[test]
fn wasm_handler_oversized_output_returns_output_too_large() {
let _guard = wasm_test_guard();
let (loaded, _) = match prepare_malicious_handler() {
Some(v) => v,
None => return,
};
let model = serde_json::json!({"mode": "oversized-output"});
let handler = WasmInvokeHandler::new(&loaded, &model).unwrap();
let result = handler.invoke("rillml.linearRegression.predict", &serde_json::json!({}));
assert!(result.is_err());
let error = result.unwrap_err();
assert!(
matches!(error.kind(), InvokeErrorKind::OutputTooLarge),
"expected OutputTooLarge, got: {:?}",
error.kind()
);
assert_eq!(error.stable_code(), "handlerOutputTooLarge");
}
#[test]
fn wasm_handler_invalid_json_output_returns_invalid_output() {
let _guard = wasm_test_guard();
let (loaded, _) = match prepare_malicious_handler() {
Some(v) => v,
None => return,
};
let model = serde_json::json!({"mode": "invalid-json"});
let handler = WasmInvokeHandler::new(&loaded, &model).unwrap();
let result = handler.invoke("rillml.linearRegression.predict", &serde_json::json!({}));
assert!(result.is_err());
let error = result.unwrap_err();
assert!(
matches!(error.kind(), InvokeErrorKind::InvalidOutput),
"expected InvalidOutput, got: {:?}",
error.kind()
);
assert_eq!(error.stable_code(), "handlerInvalidOutput");
}
#[test]
fn wasm_handler_infinite_loop_returns_timeout() {
let _guard = wasm_test_guard();
let (loaded, _) = match prepare_malicious_handler() {
Some(v) => v,
None => return,
};
let model = serde_json::json!({"mode": "infinite-loop"});
let handler = WasmInvokeHandler::new(&loaded, &model).unwrap();
let start = std::time::Instant::now();
let result = handler.invoke("rillml.linearRegression.predict", &serde_json::json!({}));
let elapsed = start.elapsed();
assert!(result.is_err());
let error = result.unwrap_err();
assert!(
matches!(error.kind(), InvokeErrorKind::Timeout),
"expected Timeout, got: {:?}",
error.kind()
);
assert_eq!(error.stable_code(), "handlerTimeout");
assert!(error.retryable());
assert!(
elapsed.as_secs() < 15,
"infinite loop took too long to interrupt: {elapsed:?}"
);
}
#[test]
fn wasm_handler_echo_mode_works_as_baseline() {
let _guard = wasm_test_guard();
let (loaded, _) = match prepare_malicious_handler() {
Some(v) => v,
None => return,
};
let model = serde_json::json!({"mode": "echo"});
let handler = WasmInvokeHandler::new(&loaded, &model).unwrap();
let input = serde_json::json!({"features": [1.0, 2.0]});
let output = handler
.invoke("rillml.linearRegression.predict", &input)
.unwrap();
assert_eq!(output, input);
}
#[test]
fn wasm_handler_remains_usable_after_output_too_large() {
let _guard = wasm_test_guard();
let (loaded, _) = match prepare_malicious_handler() {
Some(v) => v,
None => return,
};
let model = serde_json::json!({"mode": "oversized-output"});
let handler = WasmInvokeHandler::new(&loaded, &model).unwrap();
let result = handler.invoke("rillml.linearRegression.predict", &serde_json::json!({}));
assert!(result.is_err());
let error = result.unwrap_err();
assert!(matches!(error.kind(), InvokeErrorKind::OutputTooLarge));
let result = handler.invoke("rillml.linearRegression.predict", &serde_json::json!({}));
assert!(result.is_err());
let error = result.unwrap_err();
assert!(
matches!(error.kind(), InvokeErrorKind::ExecutionFailed),
"expected ExecutionFailed on second call, got: {:?}",
error.kind()
);
}
#[test]
fn wasm_handler_failure_does_not_affect_other_instances() {
let _guard = wasm_test_guard();
let (loaded, _) = match prepare_malicious_handler() {
Some(v) => v,
None => return,
};
let trap_model = serde_json::json!({"mode": "trap"});
let trap_handler = WasmInvokeHandler::new(&loaded, &trap_model).unwrap();
let result = trap_handler.invoke("rillml.linearRegression.predict", &serde_json::json!({}));
assert!(matches!(result.unwrap_err().kind(), InvokeErrorKind::Trap));
let echo_model = serde_json::json!({"mode": "echo"});
let echo_handler = WasmInvokeHandler::new(&loaded, &echo_model).unwrap();
let input = serde_json::json!({"features": [3.0, 4.0]});
let output = echo_handler
.invoke("rillml.linearRegression.predict", &input)
.unwrap();
assert_eq!(output, input);
}
#[test]
fn wasm_handler_configure_infinite_loop_returns_timeout() {
let _guard = wasm_test_guard();
let (loaded, _) = match prepare_malicious_handler() {
Some(v) => v,
None => return,
};
let model = serde_json::json!({"mode": "configure-infinite-loop"});
let start = std::time::Instant::now();
let result = WasmInvokeHandler::new(&loaded, &model);
let elapsed = start.elapsed();
assert!(
result.is_err(),
"configure-infinite-loop must fail handler load"
);
let err = result.unwrap_err();
assert!(
matches!(err, rill_runtime::HandlerLoadError::Init(ref msg)
if msg.contains("configure trap")),
"expected HandlerLoadError::Init mentioning configure trap, got: {err:?}"
);
assert!(
elapsed.as_secs() < 15,
"configure infinite loop took too long to interrupt: {elapsed:?}"
);
}
fn metadata_loop_handler_component() -> Option<PathBuf> {
if let Ok(path) = std::env::var("METADATA_LOOP_HANDLER_WASM") {
let path = PathBuf::from(path);
if path.exists() {
return Some(path);
}
}
let workspace_target = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../target/test-metadata-loop-handler.wasm");
if workspace_target.exists() {
return Some(workspace_target);
}
None
}
fn build_metadata_loop_handler_pack(module: &[u8], signing: &SigningKey) -> Vec<u8> {
let manifest = HandlerPackManifest {
format_version: HANDLER_PACKAGE_FORMAT_VERSION,
id: "rillml.test.metadata-loop".into(),
version: env!("CARGO_PKG_VERSION").into(),
handler_api_version: HANDLER_API_VERSION,
min_runtime_version: env!("CARGO_PKG_VERSION").into(),
publisher_key_id: "wasm-test-key".into(),
capabilities: vec!["rillml.linearRegression.predict".into()],
module_sha256: hex::encode(Sha256::digest(module)),
module_size: module.len() as u64,
};
build_signed_handler_pack(&manifest, module, signing).unwrap()
}
fn load_metadata_loop_handler_pack(
pack_bytes: &[u8],
verifying: &VerifyingKey,
) -> LoadedHandlerPack {
let trust = TrustStore(BTreeMap::from([("wasm-test-key".into(), *verifying)]));
let (loaded, _) = load_handler_pack(std::io::Cursor::new(pack_bytes), &trust).unwrap();
loaded
}
fn prepare_metadata_loop_handler() -> Option<LoadedHandlerPack> {
let component = match metadata_loop_handler_component() {
Some(path) => fs::read(&path).unwrap(),
None => {
eprintln!(
"skipping: metadata-loop handler component not built (set METADATA_LOOP_HANDLER_WASM)"
);
return None;
}
};
let signing = SigningKey::from_bytes(&[9; 32]);
let pack_bytes = build_metadata_loop_handler_pack(&component, &signing);
Some(load_metadata_loop_handler_pack(
&pack_bytes,
&signing.verifying_key(),
))
}
#[test]
fn wasm_handler_metadata_infinite_loop_returns_load_error() {
let _guard = wasm_test_guard();
let loaded = match prepare_metadata_loop_handler() {
Some(v) => v,
None => return,
};
let model = serde_json::json!({});
let start = std::time::Instant::now();
let result = WasmInvokeHandler::new(&loaded, &model);
let elapsed = start.elapsed();
assert!(
result.is_err(),
"metadata-infinite-loop must fail handler load"
);
let err = result.unwrap_err();
assert!(
matches!(err, rill_runtime::HandlerLoadError::Init(ref msg)
if msg.contains("metadata trap")),
"expected HandlerLoadError::Init mentioning metadata trap, got: {err:?}"
);
assert!(
elapsed.as_secs() < 15,
"metadata infinite loop took too long to interrupt: {elapsed:?}"
);
}
#[test]
fn metadata_loop_handler_failure_does_not_leak_ticker_or_block_later_handlers() {
let _guard = wasm_test_guard();
let metadata_loaded = match prepare_metadata_loop_handler() {
Some(v) => v,
None => return,
};
let start = std::time::Instant::now();
let result = WasmInvokeHandler::new(&metadata_loaded, &serde_json::json!({}));
let elapsed = start.elapsed();
assert!(result.is_err(), "metadata-loop handler must fail to load");
assert!(
elapsed.as_secs() < 15,
"metadata-loop load must be bounded by epoch deadline: {elapsed:?}"
);
let echo_component = match echo_handler_component() {
Some(path) => fs::read(&path).unwrap(),
None => {
eprintln!("skipping: echo handler component not built (set ECHO_HANDLER_WASM)");
return;
}
};
let signing = SigningKey::from_bytes(&[7; 32]);
let pack_bytes = build_echo_pack(&echo_component, &signing);
let (echo_loaded, _) = load_echo_pack(&pack_bytes, &signing.verifying_key());
let model = serde_json::json!({"kind": "linearRegression", "weights": [0.5], "intercept": 0.0});
let echo_handler = WasmInvokeHandler::new(&echo_loaded, &model)
.expect("echo handler must load after metadata-loop failure");
let input = serde_json::json!({"features": [1.0, 2.0]});
let output = echo_handler
.invoke("rillml.linearRegression.predict", &input)
.expect("echo handler must still invoke correctly");
assert_eq!(
output, input,
"echo handler must return the input unchanged"
);
}
#[test]
fn wasm_handler_long_error_string_is_truncated() {
let _guard = wasm_test_guard();
use rill_runtime::InvokeError;
use rill_runtime::MAX_DETAIL_BYTES;
let (loaded, _) = match prepare_malicious_handler() {
Some(v) => v,
None => return,
};
let model = serde_json::json!({"mode": "long-error-string"});
let handler = WasmInvokeHandler::new(&loaded, &model).unwrap();
let result = handler.invoke("rillml.linearRegression.predict", &serde_json::json!({}));
assert!(result.is_err());
let error: InvokeError = result.unwrap_err();
assert!(
matches!(error.kind(), InvokeErrorKind::ExecutionFailed),
"expected ExecutionFailed, got: {:?}",
error.kind()
);
assert_eq!(error.stable_code(), "handlerInternalError");
let detail = error
.detail()
.expect("ExecutionFailed with detail must store host-only detail");
assert!(
detail.len() <= MAX_DETAIL_BYTES,
"detail length {} must not exceed MAX_DETAIL_BYTES {}",
detail.len(),
MAX_DETAIL_BYTES
);
assert!(
detail.chars().all(|c| c == 'X'),
"detail must be the raw guest 'X' payload, got: {detail:?}"
);
let x_count = detail.chars().filter(|c| *c == 'X').count();
assert!(
x_count >= MAX_DETAIL_BYTES - 64,
"expected at least {} 'X' characters from the guest payload, got {x_count}",
MAX_DETAIL_BYTES - 64
);
assert_eq!(error.public_message(), "handler execution failed");
assert!(!error.public_message().contains("X"));
}
#[test]
fn wasm_handler_fuel_exhaustion_returns_timeout() {
let _guard = wasm_test_guard();
let (loaded, _) = match prepare_malicious_handler() {
Some(v) => v,
None => return,
};
let model = serde_json::json!({"mode": "fuel-exhaustion"});
let handler = WasmInvokeHandler::new(&loaded, &model).unwrap();
let start = std::time::Instant::now();
let result = handler.invoke("rillml.linearRegression.predict", &serde_json::json!({}));
let elapsed = start.elapsed();
assert!(result.is_err());
let error = result.unwrap_err();
assert!(
matches!(error.kind(), InvokeErrorKind::Timeout),
"expected Timeout for fuel exhaustion, got: {:?}",
error.kind()
);
assert_eq!(error.stable_code(), "handlerTimeout");
assert!(error.retryable());
assert!(
elapsed.as_secs() < 15,
"fuel exhaustion took too long to interrupt: {elapsed:?}"
);
}