Something went wrong. Try again.
Flatpak-style launcher for sandboxed WebAssembly tools. Install, verify, and run .wasm binaries with explicit capability grants. Single binary, local-first, zero telemetry. wasmbox.orbiter.website
wasm rust wasmbox
Something went wrong. Try again.
19 kB · 582 lines
Rust
at dev
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583//! `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<PathBuf, ComplianceError> { 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<PathBuf> { 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<String, ComplianceError> { let mtime = mtime_nanos(bin)?; let cache_path = home.join("hashcache.json"); let mut cache: HashMap<String, HashCacheEntry> = 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<u128, ComplianceError> { 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<String>, pub home: PathBuf, pub shim_dir: PathBuf, pub session_id: Option<String>,}
/// 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<ViaOutcome, ComplianceError> { 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<ViaOutcome, ComplianceError> { 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<ViaOutcome, ComplianceError> { 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<R: std::io::Read, W: std::io::Write>( src: Option<R>, dst: &mut W,) -> Result<u64, ComplianceError> { 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<String>, binary_hash: Option<String>, exit_code: Option<i32>, duration_ms: Option<u64>, 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") ); }}