//! `wasmbox via` engine: resolve, hash, log, and exec real binaries. use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::process::Command; use chrono::Utc; use serde::{Deserialize, Serialize}; use crate::error::ComplianceError; use crate::policy::{ProxyMode, ProxyPolicy, default_command_mappings}; use crate::runlog::{RunLog, RunLogEntry}; /// Exit code returned when `enforce` mode blocks a command. pub const EXIT_BLOCKED: i32 = 126; /// Resolve a command name to a real executable path by walking `PATH`. /// /// `shim_dir` is excluded from the search so a shim never resolves to itself. /// An input that is already a path (contains `/`) is returned as-is if it exists. pub fn resolve_binary(cmd: &str, shim_dir: &Path) -> Result { if cmd.contains('/') { let p = PathBuf::from(cmd); if p.is_file() { return Ok(p); } return Err(ComplianceError::ProxyError(format!( "command not found: {cmd}" ))); } let path_var = std::env::var_os("PATH") .ok_or_else(|| ComplianceError::ProxyError("PATH is not set".to_string()))?; let shim_dir = shim_dir.canonicalize().ok(); for dir in std::env::split_paths(&path_var) { if let Some(ref shim) = shim_dir && dir.canonicalize().ok().as_ref() == Some(shim) { continue; } let candidate = dir.join(cmd); if candidate.is_file() { return Ok(candidate); } } Err(ComplianceError::ProxyError(format!( "command not found: {cmd}" ))) } /// Like `resolve_binary` but returns `None` instead of an error — for callers /// that just need a presence check. pub fn resolve_binary_path(cmd: &str, shim_dir: &Path) -> Option { resolve_binary(cmd, shim_dir).ok() } #[derive(Debug, Clone, Serialize, Deserialize)] struct HashCacheEntry { mtime_nanos: u128, hash: String, } /// SHA-256 of `bin`, served from `~/.wasmbox/hashcache.json` when the file's /// mtime is unchanged. Returns a `sha256:`-prefixed hex string. pub fn hash_binary_cached(bin: &Path, home: &Path) -> Result { let mtime = mtime_nanos(bin)?; let cache_path = home.join("hashcache.json"); let mut cache: HashMap = std::fs::read_to_string(&cache_path) .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default(); let key = bin.to_string_lossy().to_string(); if let Some(entry) = cache.get(&key) && entry.mtime_nanos == mtime { return Ok(entry.hash.clone()); } // `hash_file` already returns a `sha256:`-prefixed string. let hash = wasmbox_verify::hash_file(bin) .map_err(|e| ComplianceError::ProxyError(format!("hashing {}: {e}", bin.display())))?; cache.insert( key, HashCacheEntry { mtime_nanos: mtime, hash: hash.clone(), }, ); if let Some(parent) = cache_path.parent() { let _ = std::fs::create_dir_all(parent); } if let Ok(serialized) = serde_json::to_string(&cache) { let _ = std::fs::write(&cache_path, serialized); } Ok(hash) } fn mtime_nanos(p: &Path) -> Result { let meta = std::fs::metadata(p) .map_err(|e| ComplianceError::ProxyError(format!("stat {}: {e}", p.display())))?; let mtime = meta .modified() .map_err(|e| ComplianceError::ProxyError(format!("mtime {}: {e}", p.display())))?; let dur = mtime .duration_since(std::time::UNIX_EPOCH) .map_err(|e| ComplianceError::ProxyError(format!("mtime pre-epoch: {e}")))?; Ok(dur.as_nanos()) } /// Inputs for a single proxied command execution. pub struct ViaRequest { pub cmd: String, pub args: Vec, pub home: PathBuf, pub shim_dir: PathBuf, pub session_id: Option, } /// Result of a proxied execution. pub struct ViaOutcome { /// Exit code to return to the caller (128 + signo if signal-terminated). pub exit_code: i32, /// True if the child terminated by signal. pub killed: bool, } /// Resolve, hash, log, exec a real binary, and report its outcome. /// /// Fail-open: if resolution fails, the error is returned, but a `via_started` /// entry is always written first so the attempt is recorded. pub fn run_via(req: ViaRequest) -> Result { let log = RunLog::new(&req.home); let started = std::time::Instant::now(); let resolved = resolve_binary(&req.cmd, &req.shim_dir); let (path, binary_hash) = match &resolved { Ok(p) => { let hash = hash_binary_cached(p, &req.home).ok(); (Some(p.to_string_lossy().to_string()), hash) } Err(_) => (None, None), }; // Pre-exec partial entry — exists even if `via` itself is killed mid-run. let _ = log.append(&proxy_entry( &req, "via_started", path.clone(), binary_hash.clone(), None, None, false, )); let real = resolved?; let status = Command::new(&real) .args(&req.args) .status() .map_err(|e| ComplianceError::ProxyError(format!("exec {}: {e}", real.display())))?; let duration_ms = started.elapsed().as_millis() as u64; let (exit_code, killed) = decode_status(&status); let _ = log.append(&proxy_entry( &req, "via", path, binary_hash, Some(exit_code), Some(duration_ms), killed, )); Ok(ViaOutcome { exit_code, killed }) } /// `run_via` with an explicit proxy policy applied. pub fn run_via_with_policy( req: ViaRequest, policy: Option<&ProxyPolicy>, ) -> Result { let log = RunLog::new(&req.home); if let Some(p) = policy { if p.mode == ProxyMode::Enforce && !command_allowed(&req.cmd, p) { let mut entry = proxy_entry( &req, "blocked", None, None, Some(EXIT_BLOCKED), Some(0), false, ); entry.policy = "blocked".to_string(); entry.policy_reason = Some(format!("'{}' not on proxy allowlist", req.cmd)); let _ = log.append(&entry); eprintln!( "wasmbox: '{}' blocked by enforce-mode proxy policy.", req.cmd, ); return Ok(ViaOutcome { exit_code: EXIT_BLOCKED, killed: false, }); } if p.mode == ProxyMode::Suggest && std::env::var("WASMBOX_SUGGEST").as_deref() != Ok("0") && let Some(m) = default_command_mappings() .into_iter() .find(|m| m.blocked == req.cmd) { eprintln!( "wasmbox: '{}' has a WasmBox equivalent. Consider '{}'. \ Hide with WASMBOX_SUGGEST=0.", req.cmd, m.replacement, ); } } run_via(req) } /// Like `run_via`, but pipes stdout/stderr through the parent to count bytes. /// Used by `wasmbox via --capture-streams`. Interactive TTY semantics are lost. pub fn run_via_capture(req: &ViaRequest) -> Result { let log = RunLog::new(&req.home); let started = std::time::Instant::now(); let resolved = resolve_binary(&req.cmd, &req.shim_dir); let (path, binary_hash) = match &resolved { Ok(p) => ( Some(p.to_string_lossy().to_string()), hash_binary_cached(p, &req.home).ok(), ), Err(_) => (None, None), }; let _ = log.append(&proxy_entry( req, "via_started", path.clone(), binary_hash.clone(), None, None, false, )); let real = resolved?; let mut child = Command::new(&real) .args(&req.args) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() .map_err(|e| ComplianceError::ProxyError(format!("exec {}: {e}", real.display())))?; let stdout_bytes = drain_counted(child.stdout.take(), &mut std::io::stdout())?; let stderr_bytes = drain_counted(child.stderr.take(), &mut std::io::stderr())?; let status = child .wait() .map_err(|e| ComplianceError::ProxyError(format!("wait: {e}")))?; let duration_ms = started.elapsed().as_millis() as u64; let (exit_code, killed) = decode_status(&status); let mut entry = proxy_entry( req, "via", path, binary_hash, Some(exit_code), Some(duration_ms), killed, ); entry.stdout_bytes = Some(stdout_bytes); entry.stderr_bytes = Some(stderr_bytes); let _ = log.append(&entry); Ok(ViaOutcome { exit_code, killed }) } /// Copy `src` to `dst`, returning the byte count. fn drain_counted( src: Option, dst: &mut W, ) -> Result { let Some(mut src) = src else { return Ok(0) }; let mut buf = [0u8; 8192]; let mut total = 0u64; loop { let n = src .read(&mut buf) .map_err(|e| ComplianceError::ProxyError(format!("reading child stream: {e}")))?; if n == 0 { break; } dst.write_all(&buf[..n]) .map_err(|e| ComplianceError::ProxyError(format!("forwarding stream: {e}")))?; total += n as u64; } Ok(total) } /// Whether `cmd` may run under an `enforce`-mode proxy policy. fn command_allowed(cmd: &str, policy: &ProxyPolicy) -> bool { if policy.blocked_commands.iter().any(|c| c == cmd) { return false; } policy.allowed_commands.iter().any(|c| c == cmd) } #[cfg(unix)] fn decode_status(status: &std::process::ExitStatus) -> (i32, bool) { use std::os::unix::process::ExitStatusExt; if let Some(code) = status.code() { (code, false) } else if let Some(signo) = status.signal() { (128 + signo, true) } else { (1, false) } } #[cfg(not(unix))] fn decode_status(status: &std::process::ExitStatus) -> (i32, bool) { (status.code().unwrap_or(1), false) } #[allow(clippy::too_many_arguments)] fn proxy_entry( req: &ViaRequest, mode: &str, path: Option, binary_hash: Option, exit_code: Option, duration_ms: Option, killed: bool, ) -> RunLogEntry { RunLogEntry { ts: Utc::now(), tool: req.cmd.clone(), version: "via".to_string(), hash: binary_hash.clone().unwrap_or_else(|| "none".to_string()), hash_verified: false, capabilities: vec![], exit_code, duration_ms, policy: "approved".to_string(), policy_reason: None, session_id: req.session_id.clone(), mode: mode.to_string(), path, binary_hash, args: Some(req.args.clone()), killed: if killed { Some(true) } else { None }, stdin_bytes: None, stdout_bytes: None, stderr_bytes: None, } } #[cfg(test)] mod tests { use super::*; use std::sync::Mutex; /// Serializes tests that mutate the process-global `PATH`. static PATH_LOCK: Mutex<()> = Mutex::new(()); #[test] fn resolves_command_on_path() { let _guard = PATH_LOCK.lock().unwrap(); let dir = tempfile::tempdir().unwrap(); let bin = dir.path().join("mytool"); std::fs::write(&bin, "#!/bin/sh\necho hi\n").unwrap(); let shim = tempfile::tempdir().unwrap(); let old = std::env::var_os("PATH"); // SAFETY: PATH_LOCK held — no other test mutates PATH concurrently. unsafe { std::env::set_var("PATH", dir.path()); } let resolved = resolve_binary("mytool", shim.path()).unwrap(); if let Some(old) = old { unsafe { std::env::set_var("PATH", old); } } assert_eq!(resolved, bin); } #[test] fn skips_shim_dir() { let _guard = PATH_LOCK.lock().unwrap(); let shim = tempfile::tempdir().unwrap(); let real = tempfile::tempdir().unwrap(); // A "jq" shim and a real "jq" — resolution must skip the shim. std::fs::write(shim.path().join("jq"), "shim").unwrap(); let real_jq = real.path().join("jq"); std::fs::write(&real_jq, "real").unwrap(); let old = std::env::var_os("PATH"); let joined = std::env::join_paths([shim.path(), real.path()]).unwrap(); unsafe { std::env::set_var("PATH", &joined); } let resolved = resolve_binary("jq", shim.path()).unwrap(); if let Some(old) = old { unsafe { std::env::set_var("PATH", old); } } assert_eq!(resolved, real_jq); } #[test] fn missing_command_errors() { let shim = tempfile::tempdir().unwrap(); assert!(resolve_binary("definitely-not-a-real-cmd-xyz", shim.path()).is_err()); } #[test] fn hashes_and_caches_binary() { let home = tempfile::tempdir().unwrap(); let bin_dir = tempfile::tempdir().unwrap(); let bin = bin_dir.path().join("tool"); std::fs::write(&bin, b"binary contents").unwrap(); let h1 = hash_binary_cached(&bin, home.path()).unwrap(); assert!(h1.starts_with("sha256:")); assert!(home.path().join("hashcache.json").exists()); let h2 = hash_binary_cached(&bin, home.path()).unwrap(); assert_eq!(h1, h2); } #[test] fn cache_invalidates_on_mtime_change() { let home = tempfile::tempdir().unwrap(); let bin_dir = tempfile::tempdir().unwrap(); let bin = bin_dir.path().join("tool"); std::fs::write(&bin, b"version one").unwrap(); let h1 = hash_binary_cached(&bin, home.path()).unwrap(); std::thread::sleep(std::time::Duration::from_millis(10)); std::fs::write(&bin, b"version two is different").unwrap(); let h2 = hash_binary_cached(&bin, home.path()).unwrap(); assert_ne!(h1, h2); } #[test] fn run_via_passes_through_exit_code() { let _guard = PATH_LOCK.lock().unwrap(); let home = tempfile::tempdir().unwrap(); let bin_dir = tempfile::tempdir().unwrap(); let shim = tempfile::tempdir().unwrap(); let bin = bin_dir.path().join("exit7"); std::fs::write(&bin, "#!/bin/sh\nexit 7\n").unwrap(); set_executable(&bin); let old = std::env::var_os("PATH"); unsafe { std::env::set_var("PATH", bin_dir.path()); } let outcome = run_via(ViaRequest { cmd: "exit7".to_string(), args: vec![], home: home.path().to_path_buf(), shim_dir: shim.path().to_path_buf(), session_id: None, }) .unwrap(); if let Some(old) = old { unsafe { std::env::set_var("PATH", old); } } assert_eq!(outcome.exit_code, 7); assert!(!outcome.killed); let log = crate::runlog::RunLog::new(home.path()); let entries = log.read_all().unwrap(); assert_eq!(entries.len(), 2); assert_eq!(entries[0].mode, "via_started"); assert_eq!(entries[1].mode, "via"); assert_eq!(entries[1].exit_code, Some(7)); assert!(entries[1].binary_hash.is_some()); } #[cfg(unix)] fn set_executable(p: &Path) { use std::os::unix::fs::PermissionsExt; let mut perms = std::fs::metadata(p).unwrap().permissions(); perms.set_mode(0o755); std::fs::set_permissions(p, perms).unwrap(); } #[test] fn capture_streams_records_byte_counts() { let _guard = PATH_LOCK.lock().unwrap(); let home = tempfile::tempdir().unwrap(); let bin_dir = tempfile::tempdir().unwrap(); let shim = tempfile::tempdir().unwrap(); let bin = bin_dir.path().join("emit"); std::fs::write(&bin, "#!/bin/sh\nprintf 'abcde'\n").unwrap(); set_executable(&bin); let old = std::env::var_os("PATH"); unsafe { std::env::set_var("PATH", bin_dir.path()); } let req = ViaRequest { cmd: "emit".to_string(), args: vec![], home: home.path().to_path_buf(), shim_dir: shim.path().to_path_buf(), session_id: None, }; let outcome = run_via_capture(&req).unwrap(); if let Some(old) = old { unsafe { std::env::set_var("PATH", old); } } assert_eq!(outcome.exit_code, 0); let entries = crate::runlog::RunLog::new(home.path()).read_all().unwrap(); let completed = entries.iter().find(|e| e.mode == "via").unwrap(); assert_eq!(completed.stdout_bytes, Some(5)); } #[test] fn enforce_mode_blocks_command_not_on_allowlist() { let _guard = PATH_LOCK.lock().unwrap(); let home = tempfile::tempdir().unwrap(); let shim = tempfile::tempdir().unwrap(); let bin_dir = tempfile::tempdir().unwrap(); let bin = bin_dir.path().join("curl"); std::fs::write(&bin, "#!/bin/sh\nexit 0\n").unwrap(); set_executable(&bin); let policy = ProxyPolicy { mode: ProxyMode::Enforce, allowed_commands: vec!["git".to_string()], blocked_commands: vec![], }; let old = std::env::var_os("PATH"); unsafe { std::env::set_var("PATH", bin_dir.path()); } let outcome = run_via_with_policy( ViaRequest { cmd: "curl".to_string(), args: vec![], home: home.path().to_path_buf(), shim_dir: shim.path().to_path_buf(), session_id: None, }, Some(&policy), ) .unwrap(); if let Some(old) = old { unsafe { std::env::set_var("PATH", old); } } assert_eq!(outcome.exit_code, 126); let log = crate::runlog::RunLog::new(home.path()); let entries = log.read_all().unwrap(); assert!( entries .iter() .any(|e| e.mode == "blocked" && e.tool == "curl") ); } }