diff --git a/Cargo.lock b/Cargo.lock index 4dafc9b2..889cfc29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9003,8 +9003,10 @@ dependencies = [ "bincode", "chrono", "ipnet", + "platform-challenge-sdk-wasm", "reqwest 0.12.25", "serde", + "serde_json", "sha2 0.10.9", "thiserror 2.0.17", "tracing", diff --git a/bins/validator-node/src/wasm_executor.rs b/bins/validator-node/src/wasm_executor.rs index 4c886a11..5ec25adc 100644 --- a/bins/validator-node/src/wasm_executor.rs +++ b/bins/validator-node/src/wasm_executor.rs @@ -6,8 +6,8 @@ use std::sync::Arc; use std::time::Instant; use tracing::{debug, info}; use wasm_runtime_interface::{ - InstanceConfig, NetworkHostFunctions, NetworkPolicy, RuntimeConfig, WasmModule, WasmRuntime, - WasmRuntimeError, + ExecPolicy, InstanceConfig, NetworkHostFunctions, NetworkPolicy, RuntimeConfig, TimePolicy, + WasmModule, WasmRuntime, WasmRuntimeError, }; pub struct WasmExecutorConfig { @@ -83,6 +83,8 @@ impl WasmChallengeExecutor { let instance_config = InstanceConfig { network_policy: network_policy.clone(), + exec_policy: ExecPolicy::default(), + time_policy: TimePolicy::default(), audit_logger: None, memory_export: "memory".to_string(), challenge_id: module_path.to_string(), @@ -171,6 +173,8 @@ impl WasmChallengeExecutor { let instance_config = InstanceConfig { network_policy: network_policy.clone(), + exec_policy: ExecPolicy::default(), + time_policy: TimePolicy::default(), audit_logger: None, memory_export: "memory".to_string(), challenge_id: module_path.to_string(), diff --git a/crates/wasm-runtime-interface/Cargo.toml b/crates/wasm-runtime-interface/Cargo.toml index c263e57f..c8786aba 100644 --- a/crates/wasm-runtime-interface/Cargo.toml +++ b/crates/wasm-runtime-interface/Cargo.toml @@ -5,6 +5,7 @@ edition.workspace = true [dependencies] serde = { workspace = true } +serde_json = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } chrono = { workspace = true } @@ -14,4 +15,5 @@ wasmtime = "41.0.3" bincode = { workspace = true } reqwest = { workspace = true, features = ["blocking", "rustls-tls"] } trust-dns-resolver = "0.23.2" -sha2 = { workspace = true } \ No newline at end of file +sha2 = { workspace = true } +platform-challenge-sdk-wasm = { path = "../challenge-sdk-wasm" } \ No newline at end of file diff --git a/crates/wasm-runtime-interface/src/bridge.rs b/crates/wasm-runtime-interface/src/bridge.rs new file mode 100644 index 00000000..d7cc69d0 --- /dev/null +++ b/crates/wasm-runtime-interface/src/bridge.rs @@ -0,0 +1,206 @@ +use platform_challenge_sdk_wasm::{EvaluationInput, EvaluationOutput}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EvalRequest { + pub request_id: String, + pub submission_id: String, + pub participant_id: String, + pub data: serde_json::Value, + pub metadata: Option, + pub epoch: u64, + pub deadline: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EvalResponse { + pub request_id: String, + pub success: bool, + pub error: Option, + pub score: f64, + pub results: serde_json::Value, + pub execution_time_ms: i64, + pub cost: Option, +} + +impl EvalResponse { + pub fn success(request_id: &str, score: f64, results: serde_json::Value) -> Self { + Self { + request_id: request_id.to_string(), + success: true, + error: None, + score, + results, + execution_time_ms: 0, + cost: None, + } + } + + pub fn error(request_id: &str, error: impl Into) -> Self { + Self { + request_id: request_id.to_string(), + success: false, + error: Some(error.into()), + score: 0.0, + results: serde_json::Value::Null, + execution_time_ms: 0, + cost: None, + } + } + + pub fn with_time(mut self, ms: i64) -> Self { + self.execution_time_ms = ms; + self + } + + pub fn with_cost(mut self, cost: f64) -> Self { + self.cost = Some(cost); + self + } +} + +pub fn request_to_input( + req: &EvalRequest, + challenge_id: &str, +) -> Result { + let agent_data = + serde_json::to_vec(&req.data).map_err(|e| BridgeError::Serialize(format!("data: {e}")))?; + + let params = match &req.metadata { + Some(meta) => serde_json::to_vec(meta) + .map_err(|e| BridgeError::Serialize(format!("metadata: {e}")))?, + None => Vec::new(), + }; + + Ok(EvaluationInput { + agent_data, + challenge_id: challenge_id.to_string(), + params, + task_definition: None, + environment_config: None, + }) +} + +pub fn input_to_bytes(input: &EvaluationInput) -> Result, BridgeError> { + bincode::serialize(input).map_err(|e| BridgeError::Serialize(e.to_string())) +} + +pub fn bytes_to_output(bytes: &[u8]) -> Result { + bincode::deserialize(bytes).map_err(|e| BridgeError::Deserialize(e.to_string())) +} + +pub fn output_to_response( + output: &EvaluationOutput, + request_id: &str, + execution_time_ms: i64, +) -> EvalResponse { + if output.valid { + let score = output.score as f64 / 100.0; + let results = serde_json::json!({ "message": output.message }); + EvalResponse::success(request_id, score, results).with_time(execution_time_ms) + } else { + EvalResponse::error(request_id, &output.message).with_time(execution_time_ms) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum BridgeError { + #[error("serialization error: {0}")] + Serialize(String), + #[error("deserialization error: {0}")] + Deserialize(String), +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_request_to_input() { + let req = EvalRequest { + request_id: "req-1".into(), + submission_id: "sub-1".into(), + participant_id: "part-1".into(), + data: json!({"code": "print('hello')"}), + metadata: Some(json!({"lang": "python"})), + epoch: 1, + deadline: None, + }; + + let input = request_to_input(&req, "test-challenge").unwrap(); + assert_eq!(input.challenge_id, "test-challenge"); + assert!(!input.agent_data.is_empty()); + assert!(!input.params.is_empty()); + + let data: serde_json::Value = serde_json::from_slice(&input.agent_data).unwrap(); + assert_eq!(data, json!({"code": "print('hello')"})); + + let meta: serde_json::Value = serde_json::from_slice(&input.params).unwrap(); + assert_eq!(meta, json!({"lang": "python"})); + } + + #[test] + fn test_request_to_input_no_metadata() { + let req = EvalRequest { + request_id: "req-1".into(), + submission_id: "sub-1".into(), + participant_id: "part-1".into(), + data: json!("test"), + metadata: None, + epoch: 0, + deadline: None, + }; + + let input = request_to_input(&req, "ch").unwrap(); + assert!(input.params.is_empty()); + } + + #[test] + fn test_roundtrip_input_bytes() { + let input = EvaluationInput { + agent_data: vec![1, 2, 3], + challenge_id: "test".into(), + params: vec![4, 5, 6], + task_definition: None, + environment_config: None, + }; + + let bytes = input_to_bytes(&input).unwrap(); + let recovered: EvaluationInput = bincode::deserialize(&bytes).unwrap(); + assert_eq!(recovered.agent_data, input.agent_data); + assert_eq!(recovered.challenge_id, input.challenge_id); + assert_eq!(recovered.params, input.params); + } + + #[test] + fn test_bytes_to_output() { + let output = EvaluationOutput::success(85, "great job"); + let bytes = bincode::serialize(&output).unwrap(); + let recovered = bytes_to_output(&bytes).unwrap(); + assert_eq!(recovered.score, 85); + assert!(recovered.valid); + assert_eq!(recovered.message, "great job"); + } + + #[test] + fn test_output_to_response_success() { + let output = EvaluationOutput::success(100, "perfect"); + let resp = output_to_response(&output, "req-1", 42); + assert!(resp.success); + assert_eq!(resp.request_id, "req-1"); + assert!((resp.score - 1.0).abs() < f64::EPSILON); + assert_eq!(resp.execution_time_ms, 42); + assert!(resp.error.is_none()); + } + + #[test] + fn test_output_to_response_failure() { + let output = EvaluationOutput::failure("bad input"); + let resp = output_to_response(&output, "req-2", 10); + assert!(!resp.success); + assert_eq!(resp.request_id, "req-2"); + assert!((resp.score - 0.0).abs() < f64::EPSILON); + assert_eq!(resp.error.as_deref(), Some("bad input")); + } +} diff --git a/crates/wasm-runtime-interface/src/exec.rs b/crates/wasm-runtime-interface/src/exec.rs new file mode 100644 index 00000000..bcca8dd0 --- /dev/null +++ b/crates/wasm-runtime-interface/src/exec.rs @@ -0,0 +1,620 @@ +use crate::runtime::{HostFunctionRegistrar, RuntimeState, WasmRuntimeError}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::process::Command; +use std::time::{Duration, Instant}; +use tracing::{info, warn}; +use wasmtime::{Caller, Linker, Memory}; + +pub const HOST_EXEC_NAMESPACE: &str = "platform_exec"; +pub const HOST_EXEC_COMMAND: &str = "exec_command"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExecHostFunction { + ExecCommand, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExecPolicy { + pub enabled: bool, + pub allowed_commands: Vec, + pub timeout_ms: u64, + pub max_output_bytes: u64, + pub max_executions: u32, + pub allowed_env_vars: Vec, + pub blocked_args: Vec, +} + +impl Default for ExecPolicy { + fn default() -> Self { + Self { + enabled: false, + allowed_commands: Vec::new(), + timeout_ms: 5_000, + max_output_bytes: 512 * 1024, + max_executions: 8, + allowed_env_vars: Vec::new(), + blocked_args: vec![ + "..".to_string(), + "/etc".to_string(), + "/proc".to_string(), + "/sys".to_string(), + ], + } + } +} + +impl ExecPolicy { + pub fn development() -> Self { + Self { + enabled: true, + allowed_commands: vec![ + "echo".to_string(), + "cat".to_string(), + "ls".to_string(), + "wc".to_string(), + "grep".to_string(), + "head".to_string(), + "tail".to_string(), + ], + timeout_ms: 15_000, + max_output_bytes: 2 * 1024 * 1024, + max_executions: 32, + allowed_env_vars: Vec::new(), + blocked_args: vec![ + "..".to_string(), + "/etc/shadow".to_string(), + "/etc/passwd".to_string(), + ], + } + } + + pub fn is_command_allowed(&self, command: &str) -> bool { + if !self.enabled { + return false; + } + self.allowed_commands.iter().any(|c| c == command) + } + + pub fn are_args_allowed(&self, args: &[String]) -> bool { + for arg in args { + for blocked in &self.blocked_args { + if arg.contains(blocked.as_str()) { + return false; + } + } + } + true + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExecRequest { + pub command: String, + pub args: Vec, + pub env: HashMap, + pub stdin: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExecResponse { + pub exit_code: i32, + pub stdout: Vec, + pub stderr: Vec, +} + +#[derive(Debug, thiserror::Error, Serialize, Deserialize)] +pub enum ExecError { + #[error("exec disabled")] + Disabled, + #[error("command not allowed: {0}")] + CommandNotAllowed(String), + #[error("args not allowed: {0}")] + ArgsNotAllowed(String), + #[error("env var not allowed: {0}")] + EnvVarNotAllowed(String), + #[error("execution limit exceeded")] + LimitExceeded, + #[error("execution timeout")] + Timeout, + #[error("output too large: {0}")] + OutputTooLarge(u64), + #[error("execution failed: {0}")] + Failed(String), +} + +pub struct ExecState { + policy: ExecPolicy, + executions: u32, + challenge_id: String, + validator_id: String, +} + +impl ExecState { + pub fn new(policy: ExecPolicy, challenge_id: String, validator_id: String) -> Self { + Self { + policy, + executions: 0, + challenge_id, + validator_id, + } + } + + pub fn executions(&self) -> u32 { + self.executions + } + + pub fn reset_counters(&mut self) { + self.executions = 0; + } + + pub fn handle_exec(&mut self, request: ExecRequest) -> Result { + if !self.policy.enabled { + return Err(ExecError::Disabled); + } + + if !self.policy.is_command_allowed(&request.command) { + warn!( + challenge_id = %self.challenge_id, + validator_id = %self.validator_id, + command = %request.command, + "exec command not allowed" + ); + return Err(ExecError::CommandNotAllowed(request.command)); + } + + if !self.policy.are_args_allowed(&request.args) { + warn!( + challenge_id = %self.challenge_id, + validator_id = %self.validator_id, + command = %request.command, + "exec args not allowed" + ); + return Err(ExecError::ArgsNotAllowed(request.args.join(" "))); + } + + for key in request.env.keys() { + if !self.policy.allowed_env_vars.is_empty() + && !self.policy.allowed_env_vars.contains(key) + { + return Err(ExecError::EnvVarNotAllowed(key.clone())); + } + } + + if self.executions >= self.policy.max_executions { + return Err(ExecError::LimitExceeded); + } + + self.executions = self.executions.saturating_add(1); + + let start = Instant::now(); + let timeout = Duration::from_millis(self.policy.timeout_ms); + + let mut cmd = Command::new(&request.command); + cmd.args(&request.args); + cmd.env_clear(); + for (key, value) in &request.env { + cmd.env(key, value); + } + + if !request.stdin.is_empty() { + cmd.stdin(std::process::Stdio::piped()); + } else { + cmd.stdin(std::process::Stdio::null()); + } + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + + let mut child = cmd.spawn().map_err(|e| ExecError::Failed(e.to_string()))?; + + if !request.stdin.is_empty() { + if let Some(ref mut stdin) = child.stdin { + use std::io::Write; + let _ = stdin.write_all(&request.stdin); + } + child.stdin.take(); + } + + let output = loop { + if start.elapsed() > timeout { + let _ = child.kill(); + return Err(ExecError::Timeout); + } + match child.try_wait() { + Ok(Some(_)) => { + break child + .wait_with_output() + .map_err(|e| ExecError::Failed(e.to_string()))? + } + Ok(None) => std::thread::sleep(Duration::from_millis(10)), + Err(e) => return Err(ExecError::Failed(e.to_string())), + } + }; + + let stdout_len = output.stdout.len() as u64; + let stderr_len = output.stderr.len() as u64; + let total = stdout_len.saturating_add(stderr_len); + if total > self.policy.max_output_bytes { + return Err(ExecError::OutputTooLarge(total)); + } + + info!( + challenge_id = %self.challenge_id, + validator_id = %self.validator_id, + command = %request.command, + exit_code = output.status.code().unwrap_or(-1), + stdout_bytes = stdout_len, + stderr_bytes = stderr_len, + elapsed_ms = start.elapsed().as_millis() as u64, + "exec command completed" + ); + + Ok(ExecResponse { + exit_code: output.status.code().unwrap_or(-1), + stdout: output.stdout, + stderr: output.stderr, + }) + } +} + +#[derive(Clone, Debug)] +pub struct ExecHostFunctions { + enabled: Vec, +} + +impl ExecHostFunctions { + pub fn new(enabled: Vec) -> Self { + Self { enabled } + } + + pub fn all() -> Self { + Self { + enabled: vec![ExecHostFunction::ExecCommand], + } + } +} + +impl Default for ExecHostFunctions { + fn default() -> Self { + Self::all() + } +} + +impl HostFunctionRegistrar for ExecHostFunctions { + fn register(&self, linker: &mut Linker) -> Result<(), WasmRuntimeError> { + if self.enabled.contains(&ExecHostFunction::ExecCommand) { + linker + .func_wrap( + HOST_EXEC_NAMESPACE, + HOST_EXEC_COMMAND, + |mut caller: Caller, + req_ptr: i32, + req_len: i32, + resp_ptr: i32, + resp_len: i32| + -> i32 { + handle_exec_command(&mut caller, req_ptr, req_len, resp_ptr, resp_len) + }, + ) + .map_err(|err| WasmRuntimeError::HostFunction(err.to_string()))?; + } + + Ok(()) + } +} + +fn handle_exec_command( + caller: &mut Caller, + req_ptr: i32, + req_len: i32, + resp_ptr: i32, + resp_len: i32, +) -> i32 { + let request_bytes = match read_memory(caller, req_ptr, req_len) { + Ok(bytes) => bytes, + Err(err) => { + warn!( + challenge_id = %caller.data().challenge_id, + validator_id = %caller.data().validator_id, + error = %err, + "exec host memory read failed" + ); + return write_result::( + caller, + resp_ptr, + resp_len, + Err(ExecError::Failed(err)), + ); + } + }; + + let request = match bincode::deserialize::(&request_bytes) { + Ok(req) => req, + Err(err) => { + warn!( + challenge_id = %caller.data().challenge_id, + validator_id = %caller.data().validator_id, + error = %err, + "exec request decode failed" + ); + return write_result::( + caller, + resp_ptr, + resp_len, + Err(ExecError::Failed(format!("invalid exec request: {err}"))), + ); + } + }; + + let result = caller.data_mut().exec_state.handle_exec(request); + if let Err(ref err) = result { + warn!( + challenge_id = %caller.data().challenge_id, + validator_id = %caller.data().validator_id, + error = %err, + "exec command denied" + ); + } + write_result(caller, resp_ptr, resp_len, result) +} + +fn read_memory(caller: &mut Caller, ptr: i32, len: i32) -> Result, String> { + if ptr < 0 || len < 0 { + return Err("negative pointer/length".to_string()); + } + let ptr = ptr as usize; + let len = len as usize; + let memory = get_memory(caller).ok_or_else(|| "memory export not found".to_string())?; + let data = memory.data(caller); + let end = ptr + .checked_add(len) + .ok_or_else(|| "pointer overflow".to_string())?; + if end > data.len() { + return Err("memory read out of bounds".to_string()); + } + Ok(data[ptr..end].to_vec()) +} + +fn write_result( + caller: &mut Caller, + resp_ptr: i32, + resp_len: i32, + result: Result, +) -> i32 { + let response_bytes = match bincode::serialize(&result) { + Ok(bytes) => bytes, + Err(err) => { + warn!(error = %err, "failed to serialize exec response"); + return -1; + } + }; + + write_bytes(caller, resp_ptr, resp_len, &response_bytes) +} + +fn write_bytes( + caller: &mut Caller, + resp_ptr: i32, + resp_len: i32, + bytes: &[u8], +) -> i32 { + if resp_ptr < 0 || resp_len < 0 { + return -1; + } + if bytes.len() > i32::MAX as usize { + return -1; + } + let resp_len = resp_len as usize; + if bytes.len() > resp_len { + return -(bytes.len() as i32); + } + + let memory = match get_memory(caller) { + Some(memory) => memory, + None => return -1, + }; + + let ptr = resp_ptr as usize; + let end = match ptr.checked_add(bytes.len()) { + Some(end) => end, + None => return -1, + }; + let data = memory.data_mut(caller); + if end > data.len() { + return -1; + } + data[ptr..end].copy_from_slice(bytes); + bytes.len() as i32 +} + +fn get_memory(caller: &mut Caller) -> Option { + let memory_export = caller.data().memory_export.clone(); + caller + .get_export(&memory_export) + .and_then(|export| export.into_memory()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_exec_policy_default_disabled() { + let policy = ExecPolicy::default(); + assert!(!policy.enabled); + assert!(policy.allowed_commands.is_empty()); + } + + #[test] + fn test_exec_policy_development() { + let policy = ExecPolicy::development(); + assert!(policy.enabled); + assert!(policy.is_command_allowed("echo")); + assert!(policy.is_command_allowed("cat")); + assert!(!policy.is_command_allowed("rm")); + } + + #[test] + fn test_exec_policy_command_allowlist() { + let policy = ExecPolicy { + enabled: true, + allowed_commands: vec!["echo".to_string(), "ls".to_string()], + ..Default::default() + }; + + assert!(policy.is_command_allowed("echo")); + assert!(policy.is_command_allowed("ls")); + assert!(!policy.is_command_allowed("rm")); + assert!(!policy.is_command_allowed("cat")); + } + + #[test] + fn test_exec_policy_disabled_blocks_all() { + let policy = ExecPolicy { + enabled: false, + allowed_commands: vec!["echo".to_string()], + ..Default::default() + }; + + assert!(!policy.is_command_allowed("echo")); + } + + #[test] + fn test_exec_policy_blocked_args() { + let policy = ExecPolicy::default(); + + assert!(!policy.are_args_allowed(&["../../../etc/passwd".to_string()])); + assert!(!policy.are_args_allowed(&["/etc/shadow".to_string()])); + assert!(!policy.are_args_allowed(&["/proc/self/maps".to_string()])); + assert!(policy.are_args_allowed(&["hello".to_string(), "world".to_string()])); + } + + #[test] + fn test_exec_state_creation() { + let state = ExecState::new( + ExecPolicy::development(), + "test-challenge".into(), + "test-validator".into(), + ); + assert_eq!(state.executions(), 0); + } + + #[test] + fn test_exec_state_disabled() { + let mut state = ExecState::new(ExecPolicy::default(), "test".into(), "test".into()); + + let req = ExecRequest { + command: "echo".to_string(), + args: vec!["hello".to_string()], + env: HashMap::new(), + stdin: Vec::new(), + }; + + let err = state.handle_exec(req).unwrap_err(); + assert!(matches!(err, ExecError::Disabled)); + } + + #[test] + fn test_exec_state_command_not_allowed() { + let mut state = ExecState::new(ExecPolicy::development(), "test".into(), "test".into()); + + let req = ExecRequest { + command: "rm".to_string(), + args: vec!["-rf".to_string(), "/".to_string()], + env: HashMap::new(), + stdin: Vec::new(), + }; + + let err = state.handle_exec(req).unwrap_err(); + assert!(matches!(err, ExecError::CommandNotAllowed(_))); + } + + #[test] + fn test_exec_state_limit_exceeded() { + let mut state = ExecState::new( + ExecPolicy { + enabled: true, + allowed_commands: vec!["echo".to_string()], + max_executions: 1, + ..Default::default() + }, + "test".into(), + "test".into(), + ); + + let req = ExecRequest { + command: "echo".to_string(), + args: vec!["hello".to_string()], + env: HashMap::new(), + stdin: Vec::new(), + }; + + let result = state.handle_exec(req.clone()); + assert!(result.is_ok()); + + let err = state.handle_exec(req).unwrap_err(); + assert!(matches!(err, ExecError::LimitExceeded)); + } + + #[test] + fn test_exec_state_reset_counters() { + let mut state = ExecState::new(ExecPolicy::development(), "test".into(), "test".into()); + + state.executions = 5; + state.reset_counters(); + assert_eq!(state.executions(), 0); + } + + #[test] + fn test_exec_state_env_var_not_allowed() { + let mut state = ExecState::new( + ExecPolicy { + enabled: true, + allowed_commands: vec!["echo".to_string()], + allowed_env_vars: vec!["PATH".to_string()], + ..Default::default() + }, + "test".into(), + "test".into(), + ); + + let mut env = HashMap::new(); + env.insert("SECRET".to_string(), "value".to_string()); + + let req = ExecRequest { + command: "echo".to_string(), + args: vec!["hello".to_string()], + env, + stdin: Vec::new(), + }; + + let err = state.handle_exec(req).unwrap_err(); + assert!(matches!(err, ExecError::EnvVarNotAllowed(_))); + } + + #[test] + fn test_exec_echo_command() { + let mut state = ExecState::new( + ExecPolicy { + enabled: true, + allowed_commands: vec!["echo".to_string()], + ..Default::default() + }, + "test".into(), + "test".into(), + ); + + let req = ExecRequest { + command: "echo".to_string(), + args: vec!["hello".to_string()], + env: HashMap::new(), + stdin: Vec::new(), + }; + + let resp = state.handle_exec(req).unwrap(); + assert_eq!(resp.exit_code, 0); + assert_eq!(String::from_utf8_lossy(&resp.stdout).trim(), "hello"); + assert_eq!(state.executions(), 1); + } +} diff --git a/crates/wasm-runtime-interface/src/lib.rs b/crates/wasm-runtime-interface/src/lib.rs index d9cdba9b..34227647 100644 --- a/crates/wasm-runtime-interface/src/lib.rs +++ b/crates/wasm-runtime-interface/src/lib.rs @@ -9,9 +9,20 @@ use std::collections::HashMap; use std::net::IpAddr; use std::str::FromStr; +pub mod bridge; +pub mod exec; pub mod network; pub mod runtime; pub mod storage; +pub mod time; +pub use bridge::{ + bytes_to_output, input_to_bytes, output_to_response, request_to_input, BridgeError, + EvalRequest, EvalResponse, +}; +pub use exec::{ + ExecError, ExecHostFunction, ExecHostFunctions, ExecPolicy, ExecRequest, ExecResponse, + ExecState, +}; pub use network::{NetworkHostFunctions, NetworkState, NetworkStateError}; pub use storage::{ InMemoryStorageBackend, NoopStorageBackend, StorageAuditEntry, StorageAuditLogger, @@ -34,6 +45,7 @@ pub use storage::{ HOST_STORAGE_ALLOC, HOST_STORAGE_DELETE, HOST_STORAGE_GET, HOST_STORAGE_GET_RESULT, HOST_STORAGE_NAMESPACE, HOST_STORAGE_PROPOSE_WRITE, }; +pub use time::{TimeError, TimeHostFunction, TimeHostFunctions, TimeMode, TimePolicy, TimeState}; /// Host functions that may be exposed to WASM challenges. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] diff --git a/crates/wasm-runtime-interface/src/runtime.rs b/crates/wasm-runtime-interface/src/runtime.rs index a1578a75..909d0adc 100644 --- a/crates/wasm-runtime-interface/src/runtime.rs +++ b/crates/wasm-runtime-interface/src/runtime.rs @@ -1,5 +1,9 @@ +use crate::bridge::{self, BridgeError, EvalRequest, EvalResponse}; +use crate::exec::{ExecPolicy, ExecState}; +use crate::time::{TimePolicy, TimeState}; use crate::{NetworkAuditLogger, NetworkPolicy, NetworkState}; use std::sync::Arc; +use std::time::Instant; use thiserror::Error; use tracing::info; use wasmtime::{ @@ -29,6 +33,8 @@ pub enum WasmRuntimeError { FuelExhausted, #[error("policy violation: {0}")] PolicyViolation(String), + #[error("bridge error: {0}")] + Bridge(String), } impl From for WasmRuntimeError { @@ -48,6 +54,12 @@ impl From for WasmRuntimeError { } } +impl From for WasmRuntimeError { + fn from(err: BridgeError) -> Self { + Self::Bridge(err.to_string()) + } +} + pub trait HostFunctionRegistrar: Send + Sync { fn register(&self, linker: &mut Linker) -> Result<(), WasmRuntimeError>; } @@ -75,6 +87,10 @@ impl Default for RuntimeConfig { pub struct InstanceConfig { /// Network policy enforced by host functions. pub network_policy: NetworkPolicy, + /// Exec policy enforced by host functions. + pub exec_policy: ExecPolicy, + /// Time policy enforced by host functions. + pub time_policy: TimePolicy, /// Optional audit logger for network calls. pub audit_logger: Option>, /// Wasm memory export name. @@ -93,6 +109,8 @@ impl Default for InstanceConfig { fn default() -> Self { Self { network_policy: NetworkPolicy::default(), + exec_policy: ExecPolicy::default(), + time_policy: TimePolicy::default(), audit_logger: None, memory_export: DEFAULT_WASM_MEMORY_NAME.to_string(), challenge_id: "unknown".to_string(), @@ -108,6 +126,10 @@ pub struct RuntimeState { pub network_policy: NetworkPolicy, /// Mutable network state enforcing policy. pub network_state: NetworkState, + /// Mutable exec state enforcing policy. + pub exec_state: ExecState, + /// Time state for deterministic or real timestamps. + pub time_state: TimeState, /// Wasm memory export name. pub memory_export: String, /// Identifier used in audit logs. @@ -126,6 +148,8 @@ impl RuntimeState { pub fn new( network_policy: NetworkPolicy, network_state: NetworkState, + exec_state: ExecState, + time_state: TimeState, memory_export: String, challenge_id: String, validator_id: String, @@ -136,6 +160,8 @@ impl RuntimeState { Self { network_policy, network_state, + exec_state, + time_state, memory_export, challenge_id, validator_id, @@ -148,6 +174,10 @@ impl RuntimeState { pub fn reset_network_counters(&mut self) { self.network_state.reset_counters(); } + + pub fn reset_exec_counters(&mut self) { + self.exec_state.reset_counters(); + } } impl ResourceLimiter for RuntimeState { @@ -212,9 +242,21 @@ impl WasmRuntime { instance_config.validator_id.clone(), ) .map_err(|err| WasmRuntimeError::HostFunction(err.to_string()))?; + let exec_state = ExecState::new( + instance_config.exec_policy.clone(), + instance_config.challenge_id.clone(), + instance_config.validator_id.clone(), + ); + let time_state = TimeState::new( + instance_config.time_policy.clone(), + instance_config.challenge_id.clone(), + instance_config.validator_id.clone(), + ); let runtime_state = RuntimeState::new( instance_config.network_policy.clone(), network_state, + exec_state, + time_state, instance_config.memory_export.clone(), instance_config.challenge_id.clone(), instance_config.validator_id.clone(), @@ -402,6 +444,58 @@ impl ChallengeInstance { &self.store.data().validator_id } + pub fn exec_executions(&self) -> u32 { + self.store.data().exec_state.executions() + } + + pub fn reset_exec_state(&mut self) { + self.store.data_mut().reset_exec_counters(); + } + + pub fn evaluate_request(&mut self, req: EvalRequest) -> Result { + let start = Instant::now(); + let request_id = req.request_id.clone(); + let challenge_id = self.store.data().challenge_id.clone(); + + let input = bridge::request_to_input(&req, &challenge_id)?; + let input_bytes = bridge::input_to_bytes(&input)?; + + let alloc_func = self + .instance + .get_typed_func::(&mut self.store, "alloc") + .map_err(|_| WasmRuntimeError::MissingExport("alloc".to_string()))?; + + let ptr = alloc_func + .call(&mut self.store, input_bytes.len() as i32) + .map_err(|err: WasmtimeError| WasmRuntimeError::Execution(err.to_string()))?; + + if ptr == 0 { + return Err(WasmRuntimeError::Memory( + "alloc returned null pointer".to_string(), + )); + } + + self.write_memory(ptr as usize, &input_bytes)?; + + let packed = self.call_i32_i32_return_i64("evaluate", ptr, input_bytes.len() as i32)?; + + let out_len = (packed >> 32) as i32; + let out_ptr = (packed & 0xFFFF_FFFF) as i32; + + if out_ptr == 0 && out_len == 0 { + return Ok( + EvalResponse::error(&request_id, "WASM evaluate returned null") + .with_time(start.elapsed().as_millis() as i64), + ); + } + + let output_bytes = self.read_memory(out_ptr as usize, out_len as usize)?; + let output = bridge::bytes_to_output(&output_bytes)?; + + let elapsed_ms = start.elapsed().as_millis() as i64; + Ok(bridge::output_to_response(&output, &request_id, elapsed_ms)) + } + pub fn with_state(&mut self, func: F) -> Result where F: FnOnce(&mut RuntimeState) -> Result, diff --git a/crates/wasm-runtime-interface/src/time.rs b/crates/wasm-runtime-interface/src/time.rs new file mode 100644 index 00000000..756c1a41 --- /dev/null +++ b/crates/wasm-runtime-interface/src/time.rs @@ -0,0 +1,221 @@ +use crate::runtime::{HostFunctionRegistrar, RuntimeState, WasmRuntimeError}; +use serde::{Deserialize, Serialize}; +use tracing::warn; +use wasmtime::{Caller, Linker}; + +pub const HOST_TIME_NAMESPACE: &str = "platform_time"; +pub const HOST_GET_TIMESTAMP: &str = "get_timestamp"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TimeHostFunction { + GetTimestamp, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TimeMode { + Real, + #[default] + Deterministic, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TimePolicy { + pub enabled: bool, + pub mode: TimeMode, + pub fixed_timestamp_ms: u64, +} + +impl Default for TimePolicy { + fn default() -> Self { + Self { + enabled: true, + mode: TimeMode::Deterministic, + fixed_timestamp_ms: 1_700_000_000_000, + } + } +} + +impl TimePolicy { + pub fn real() -> Self { + Self { + enabled: true, + mode: TimeMode::Real, + fixed_timestamp_ms: 0, + } + } + + pub fn deterministic(timestamp_ms: u64) -> Self { + Self { + enabled: true, + mode: TimeMode::Deterministic, + fixed_timestamp_ms: timestamp_ms, + } + } + + pub fn development() -> Self { + Self::real() + } +} + +#[allow(dead_code)] +pub struct TimeState { + policy: TimePolicy, + challenge_id: String, + validator_id: String, +} + +impl TimeState { + pub fn new(policy: TimePolicy, challenge_id: String, validator_id: String) -> Self { + Self { + policy, + challenge_id, + validator_id, + } + } + + pub fn get_timestamp(&self) -> Result { + if !self.policy.enabled { + return Err(TimeError::Disabled); + } + + match self.policy.mode { + TimeMode::Real => { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| TimeError::Failed(e.to_string()))?; + Ok(now.as_millis() as u64) + } + TimeMode::Deterministic => Ok(self.policy.fixed_timestamp_ms), + } + } +} + +#[derive(Debug, thiserror::Error, Serialize, Deserialize)] +pub enum TimeError { + #[error("time access disabled")] + Disabled, + #[error("time failed: {0}")] + Failed(String), +} + +#[derive(Clone, Debug)] +pub struct TimeHostFunctions { + enabled: Vec, +} + +impl TimeHostFunctions { + pub fn new(enabled: Vec) -> Self { + Self { enabled } + } + + pub fn all() -> Self { + Self { + enabled: vec![TimeHostFunction::GetTimestamp], + } + } +} + +impl Default for TimeHostFunctions { + fn default() -> Self { + Self::all() + } +} + +impl HostFunctionRegistrar for TimeHostFunctions { + fn register(&self, linker: &mut Linker) -> Result<(), WasmRuntimeError> { + if self.enabled.contains(&TimeHostFunction::GetTimestamp) { + linker + .func_wrap( + HOST_TIME_NAMESPACE, + HOST_GET_TIMESTAMP, + |mut caller: Caller| -> i64 { handle_get_timestamp(&mut caller) }, + ) + .map_err(|err| WasmRuntimeError::HostFunction(err.to_string()))?; + } + + Ok(()) + } +} + +fn handle_get_timestamp(caller: &mut Caller) -> i64 { + match caller.data().time_state.get_timestamp() { + Ok(ts) => ts as i64, + Err(err) => { + warn!( + challenge_id = %caller.data().challenge_id, + validator_id = %caller.data().validator_id, + error = %err, + "get_timestamp failed" + ); + -1 + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_time_policy_default() { + let policy = TimePolicy::default(); + assert!(policy.enabled); + assert_eq!(policy.mode, TimeMode::Deterministic); + assert_eq!(policy.fixed_timestamp_ms, 1_700_000_000_000); + } + + #[test] + fn test_time_policy_real() { + let policy = TimePolicy::real(); + assert!(policy.enabled); + assert_eq!(policy.mode, TimeMode::Real); + } + + #[test] + fn test_time_policy_deterministic() { + let ts = 1_234_567_890_000; + let policy = TimePolicy::deterministic(ts); + assert!(policy.enabled); + assert_eq!(policy.mode, TimeMode::Deterministic); + assert_eq!(policy.fixed_timestamp_ms, ts); + } + + #[test] + fn test_time_state_deterministic() { + let state = TimeState::new( + TimePolicy::deterministic(42_000), + "test".into(), + "test".into(), + ); + assert_eq!(state.get_timestamp().unwrap(), 42_000); + } + + #[test] + fn test_time_state_real() { + let state = TimeState::new(TimePolicy::real(), "test".into(), "test".into()); + let ts = state.get_timestamp().unwrap(); + assert!(ts > 1_700_000_000_000); + } + + #[test] + fn test_time_state_disabled() { + let state = TimeState::new( + TimePolicy { + enabled: false, + ..Default::default() + }, + "test".into(), + "test".into(), + ); + let err = state.get_timestamp().unwrap_err(); + assert!(matches!(err, TimeError::Disabled)); + } + + #[test] + fn test_time_host_functions_all() { + let funcs = TimeHostFunctions::all(); + assert!(funcs.enabled.contains(&TimeHostFunction::GetTimestamp)); + } +}