From 3009d4b1a64b1894cc6a4302d9f04b98540e1d1d Mon Sep 17 00:00:00 2001 From: danbugs Date: Fri, 15 May 2026 05:28:17 +0000 Subject: [PATCH 1/6] fix: store socket type in HostSocket for correct SO_TYPE response Signed-off-by: danbugs --- host/src/lib.rs | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/host/src/lib.rs b/host/src/lib.rs index 2761587..b1a6e3a 100644 --- a/host/src/lib.rs +++ b/host/src/lib.rs @@ -1038,7 +1038,7 @@ use std::net::SocketAddr; use std::sync::Mutex; enum HostSocket { - Socket(Socket), + Socket(Socket, i32), } struct SocketTable { @@ -1069,7 +1069,13 @@ impl SocketTable { fn get_socket(&self, fd: u32) -> Result<&Socket> { match self.get(fd)? { - HostSocket::Socket(s) => Ok(s), + HostSocket::Socket(s, _) => Ok(s), + } + } + + fn get_sock_type(&self, fd: u32) -> Result { + match self.get(fd)? { + HostSocket::Socket(_, t) => Ok(*t), } } @@ -1136,7 +1142,7 @@ fn register_net_tools( Some(Protocol::from(protocol)) }; let sock = Socket::new(domain, stype, proto)?; - let fd = t.lock().unwrap().insert(HostSocket::Socket(sock)); + let fd = t.lock().unwrap().insert(HostSocket::Socket(sock, sock_type)); Ok(json!({ "fd": fd })) }); @@ -1186,13 +1192,15 @@ fn register_net_tools( let t = table.clone(); tools.register("net_accept", move |args| { let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))? as u32; - let (new_sock, peer) = { + let (new_sock, peer, parent_type) = { let tbl = t.lock().unwrap(); let sock = tbl.get_socket(fd)?; - sock.accept()? + let (s, p) = sock.accept()?; + let st = tbl.get_sock_type(fd)?; + (s, p, st) }; let peer_addr: Option = peer.as_socket(); - let new_fd = t.lock().unwrap().insert(HostSocket::Socket(new_sock)); + let new_fd = t.lock().unwrap().insert(HostSocket::Socket(new_sock, parent_type)); let mut resp = json!({ "fd": new_fd }); if let Some(pa) = peer_addr { resp["addr"] = json!(pa.ip().to_string()); @@ -1336,8 +1344,8 @@ fn register_net_tools( let tbl = t.lock().unwrap(); let sock = tbl.get_socket(fd)?; let val: i32 = if level == 1 && optname == 3 { - // SOL_SOCKET + SO_TYPE — all our sockets are SOCK_STREAM - 1 + // SOL_SOCKET + SO_TYPE — return the actual socket type + tbl.get_sock_type(fd)? } else if level == 1 && optname == 2 { sock.reuse_address()? as i32 } else if level == 6 && optname == 1 { From 87446278a0175646fefb0dc5901c3cdc8524e151 Mon Sep 17 00:00:00 2001 From: danbugs Date: Fri, 15 May 2026 05:29:05 +0000 Subject: [PATCH 2/6] fix: add process-wide mutex to stderr capture to prevent races Signed-off-by: danbugs --- host/src/lib.rs | 6 +++++- host/src/stderr_capture.rs | 16 +++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/host/src/lib.rs b/host/src/lib.rs index b1a6e3a..25a069b 100644 --- a/host/src/lib.rs +++ b/host/src/lib.rs @@ -2215,7 +2215,11 @@ pub fn run_vm_capture_output( let setup_time = setup_start.elapsed(); // Redirect stderr to a temp file before the call phase - let capture_file = std::env::temp_dir().join(format!("hl-capture-{}", std::process::id())); + let capture_file = std::env::temp_dir().join(format!( + "hl-capture-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); let capture = stderr_capture::Capture::redirect_to_file(&capture_file)?; // Phase 2: restore + call — application runs and produces output diff --git a/host/src/stderr_capture.rs b/host/src/stderr_capture.rs index ccc70b7..b1a3b3f 100644 --- a/host/src/stderr_capture.rs +++ b/host/src/stderr_capture.rs @@ -10,23 +10,37 @@ mod imp { use nix::unistd; use std::os::fd::{AsRawFd, FromRawFd, IntoRawFd, OwnedFd}; use std::path::Path; + use std::sync::{Mutex, MutexGuard}; + + /// Process-wide lock protecting fd 2 (stderr) manipulation. + /// Held from `redirect_to_file` until `restore` to prevent concurrent + /// VMs from racing on dup2. + static STDERR_LOCK: Mutex<()> = Mutex::new(()); pub struct Capture { original_stderr: OwnedFd, + _guard: MutexGuard<'static, ()>, } impl Capture { pub fn redirect_to_file(path: &Path) -> Result { + let guard = STDERR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); let capture_fd = std::fs::File::create(path)?.into_raw_fd(); let original_stderr_raw = unistd::dup(2)?; unistd::dup2(capture_fd, 2)?; unistd::close(capture_fd)?; let original_stderr = unsafe { OwnedFd::from_raw_fd(original_stderr_raw) }; - Ok(Self { original_stderr }) + Ok(Self { + original_stderr, + _guard: guard, + }) } pub fn restore(self) -> Result<()> { unistd::dup2(self.original_stderr.as_raw_fd(), 2)?; + // _guard is dropped here, releasing STDERR_LOCK Ok(()) } } From 802fdfc5e1a6494f2fe295d55316c2e9ff98d699 Mon Sep 17 00:00:00 2001 From: danbugs Date: Fri, 15 May 2026 05:30:07 +0000 Subject: [PATCH 3/6] fix: use u64 for SocketTable next_id to prevent wraparound Signed-off-by: danbugs --- host/src/lib.rs | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/host/src/lib.rs b/host/src/lib.rs index 25a069b..377e083 100644 --- a/host/src/lib.rs +++ b/host/src/lib.rs @@ -1042,8 +1042,8 @@ enum HostSocket { } struct SocketTable { - sockets: HashMap, - next_id: u32, + sockets: HashMap, + next_id: u64, } impl SocketTable { @@ -1054,32 +1054,32 @@ impl SocketTable { } } - fn insert(&mut self, sock: HostSocket) -> u32 { + fn insert(&mut self, sock: HostSocket) -> u64 { let id = self.next_id; self.next_id += 1; self.sockets.insert(id, sock); id } - fn get(&self, fd: u32) -> Result<&HostSocket> { + fn get(&self, fd: u64) -> Result<&HostSocket> { self.sockets .get(&fd) .ok_or_else(|| anyhow!("bad_fd: {}", fd)) } - fn get_socket(&self, fd: u32) -> Result<&Socket> { + fn get_socket(&self, fd: u64) -> Result<&Socket> { match self.get(fd)? { HostSocket::Socket(s, _) => Ok(s), } } - fn get_sock_type(&self, fd: u32) -> Result { + fn get_sock_type(&self, fd: u64) -> Result { match self.get(fd)? { HostSocket::Socket(_, t) => Ok(*t), } } - fn remove(&mut self, fd: u32) -> Result<()> { + fn remove(&mut self, fd: u64) -> Result<()> { self.sockets .remove(&fd) .map(|_| ()) @@ -1150,7 +1150,7 @@ fn register_net_tools( let t = table.clone(); let pol = policy.clone(); tools.register("net_connect", move |args| { - let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))? as u32; + let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))?; let addr = parse_sockaddr(&args)?; pol.check(&addr)?; let sa: SockAddr = addr.into(); @@ -1164,7 +1164,7 @@ fn register_net_tools( let t = table.clone(); let lp = listen_ports.cloned().map(Arc::new); tools.register("net_bind", move |args| { - let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))? as u32; + let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))?; let addr = parse_sockaddr(&args)?; match lp.as_ref() { Some(ports) => ports.check(addr.port())?, @@ -1180,7 +1180,7 @@ fn register_net_tools( // net_listen let t = table.clone(); tools.register("net_listen", move |args| { - let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))? as u32; + let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))?; let backlog = args["backlog"].as_i64().unwrap_or(128) as i32; let tbl = t.lock().unwrap(); let sock = tbl.get_socket(fd)?; @@ -1191,7 +1191,7 @@ fn register_net_tools( // net_accept let t = table.clone(); tools.register("net_accept", move |args| { - let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))? as u32; + let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))?; let (new_sock, peer, parent_type) = { let tbl = t.lock().unwrap(); let sock = tbl.get_socket(fd)?; @@ -1212,7 +1212,7 @@ fn register_net_tools( // net_send let t = table.clone(); tools.register("net_send", move |args| { - let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))? as u32; + let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))?; let data_b64 = args["data"] .as_str() .ok_or_else(|| anyhow!("missing 'data'"))?; @@ -1229,7 +1229,7 @@ fn register_net_tools( let t = table.clone(); let pol = policy.clone(); tools.register("net_sendto", move |args| { - let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))? as u32; + let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))?; let data_b64 = args["data"] .as_str() .ok_or_else(|| anyhow!("missing 'data'"))?; @@ -1248,7 +1248,7 @@ fn register_net_tools( // net_recv (alias for net_recvfrom with no addr returned for stream) let t = table.clone(); tools.register("net_recv", move |args| { - let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))? as u32; + let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))?; let len = args["len"].as_u64().unwrap_or(4096) as usize; let mut buf = vec![std::mem::MaybeUninit::uninit(); len.min(65536)]; let tbl = t.lock().unwrap(); @@ -1265,7 +1265,7 @@ fn register_net_tools( // net_recvfrom let t = table.clone(); tools.register("net_recvfrom", move |args| { - let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))? as u32; + let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))?; let len = args["len"].as_u64().unwrap_or(4096) as usize; let mut buf = vec![0u8; len.min(65536)]; @@ -1290,7 +1290,7 @@ fn register_net_tools( // net_close let t = table.clone(); tools.register("net_close", move |args| { - let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))? as u32; + let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))?; t.lock().unwrap().remove(fd)?; Ok(json!({})) }); @@ -1298,7 +1298,7 @@ fn register_net_tools( // net_shutdown let t = table.clone(); tools.register("net_shutdown", move |args| { - let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))? as u32; + let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))?; let how = args["how"].as_i64().unwrap_or(2) as i32; let shutdown = match how { 0 => std::net::Shutdown::Read, @@ -1314,7 +1314,7 @@ fn register_net_tools( // net_setsockopt let t = table.clone(); tools.register("net_setsockopt", move |args| { - let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))? as u32; + let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))?; let level = args["level"].as_i64().unwrap_or(0) as i32; let optname = args["optname"].as_i64().unwrap_or(0) as i32; let value = args["value"].as_i64().unwrap_or(0) as i32; @@ -1338,7 +1338,7 @@ fn register_net_tools( // net_getsockopt let t = table.clone(); tools.register("net_getsockopt", move |args| { - let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))? as u32; + let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))?; let level = args["level"].as_i64().unwrap_or(0) as i32; let optname = args["optname"].as_i64().unwrap_or(0) as i32; let tbl = t.lock().unwrap(); @@ -1359,7 +1359,7 @@ fn register_net_tools( // net_getpeername let t = table.clone(); tools.register("net_getpeername", move |args| { - let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))? as u32; + let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))?; let tbl = t.lock().unwrap(); let sock = tbl.get_socket(fd)?; let peer = sock.peer_addr()?; @@ -1373,7 +1373,7 @@ fn register_net_tools( // net_getsockname let t = table.clone(); tools.register("net_getsockname", move |args| { - let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))? as u32; + let fd = args["fd"].as_u64().ok_or_else(|| anyhow!("missing 'fd'"))?; let tbl = t.lock().unwrap(); let sock = tbl.get_socket(fd)?; let local = sock.local_addr()?; From 7c0532addaa724a26eae7e3fa5726cd63289ea3e Mon Sep 17 00:00:00 2001 From: danbugs Date: Fri, 15 May 2026 05:58:47 +0000 Subject: [PATCH 4/6] fmt: fix cargo fmt violations Signed-off-by: danbugs --- host/src/lib.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/host/src/lib.rs b/host/src/lib.rs index 377e083..7e996e4 100644 --- a/host/src/lib.rs +++ b/host/src/lib.rs @@ -1142,7 +1142,10 @@ fn register_net_tools( Some(Protocol::from(protocol)) }; let sock = Socket::new(domain, stype, proto)?; - let fd = t.lock().unwrap().insert(HostSocket::Socket(sock, sock_type)); + let fd = t + .lock() + .unwrap() + .insert(HostSocket::Socket(sock, sock_type)); Ok(json!({ "fd": fd })) }); @@ -1200,7 +1203,10 @@ fn register_net_tools( (s, p, st) }; let peer_addr: Option = peer.as_socket(); - let new_fd = t.lock().unwrap().insert(HostSocket::Socket(new_sock, parent_type)); + let new_fd = t + .lock() + .unwrap() + .insert(HostSocket::Socket(new_sock, parent_type)); let mut resp = json!({ "fd": new_fd }); if let Some(pa) = peer_addr { resp["addr"] = json!(pa.ip().to_string()); From 9a597323333d121abf2a58093ccc8bc42efcd36e Mon Sep 17 00:00:00 2001 From: danbugs Date: Fri, 15 May 2026 06:03:58 +0000 Subject: [PATCH 5/6] fix: add Drop for Capture, DGRAM SO_TYPE test, fix fd leak Signed-off-by: danbugs --- host/src/lib.rs | 32 +++++++++++++++++++++++--------- host/src/stderr_capture.rs | 31 +++++++++++++++++++++---------- 2 files changed, 44 insertions(+), 19 deletions(-) diff --git a/host/src/lib.rs b/host/src/lib.rs index 7e996e4..a1418ee 100644 --- a/host/src/lib.rs +++ b/host/src/lib.rs @@ -2818,14 +2818,11 @@ mod tests { #[test] fn test_fs_read_bytes_capped() { - // Request a huge len (well above MAX_FS_READ) on a small file. - // Without the cap this would try to allocate terabytes and OOM. let root = tmpdir("readcap"); fs::write(root.join("small.bin"), b"hello").unwrap(); let mut reg = ToolRegistry::new(); FsSandbox::new(&root).unwrap().register(&mut reg); - // Ask for 1 TiB — the cap should silently clamp to MAX_FS_READ. let req = br#"{"name":"fs_read_bytes","args":{"path":"small.bin","len":1099511627776}}"#; let resp = reg.dispatch(req); let s = std::str::from_utf8(&resp).unwrap(); @@ -2835,14 +2832,8 @@ mod tests { #[test] fn test_sleep_capped() { - // Verify the cap constant and that sleeping with a huge value - // completes quickly (the cap brings it down to 60 s max, but we - // pass 0 to keep the test instant — the important thing is - // confirming the cap constant exists and has the right value). assert_eq!(MAX_SLEEP_NS, 60_000_000_000); - // Dispatch a sleep with ns=0 through the real handler to confirm - // the code path works. let mut tools = ToolRegistry::new(); let exit_code = Arc::new(AtomicI32::new(0)); register_internal_tools(&mut tools, &exit_code, None, None); @@ -2852,4 +2843,27 @@ mod tests { let s = std::str::from_utf8(&resp).unwrap(); assert!(!s.contains("\"error\""), "sleep(0) should succeed: {s}"); } + + #[test] + fn net_getsockopt_returns_correct_type_for_dgram() { + let mut reg = ToolRegistry::new(); + let policy = NetworkPolicy::AllowAll; + register_net_tools(&mut reg, &policy, None); + + let req = br#"{"name":"net_socket","args":{"family":2,"type":2}}"#; + let resp = std::str::from_utf8(®.dispatch(req)).unwrap().to_string(); + let v: serde_json::Value = serde_json::from_str(&resp).unwrap(); + let fd = v["result"]["fd"].as_u64().unwrap(); + + let req = + format!(r#"{{"name":"net_getsockopt","args":{{"fd":{fd},"level":1,"optname":3}}}}"#); + let resp = std::str::from_utf8(®.dispatch(req.as_bytes())) + .unwrap() + .to_string(); + let v: serde_json::Value = serde_json::from_str(&resp).unwrap(); + assert_eq!( + v["result"]["value"], 2, + "SO_TYPE should return 2 (DGRAM), got: {resp}" + ); + } } diff --git a/host/src/stderr_capture.rs b/host/src/stderr_capture.rs index b1a3b3f..73339b9 100644 --- a/host/src/stderr_capture.rs +++ b/host/src/stderr_capture.rs @@ -8,7 +8,7 @@ mod imp { use anyhow::Result; use nix::unistd; - use std::os::fd::{AsRawFd, FromRawFd, IntoRawFd, OwnedFd}; + use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; use std::path::Path; use std::sync::{Mutex, MutexGuard}; @@ -18,7 +18,7 @@ mod imp { static STDERR_LOCK: Mutex<()> = Mutex::new(()); pub struct Capture { - original_stderr: OwnedFd, + original_stderr: Option, _guard: MutexGuard<'static, ()>, } @@ -27,23 +27,34 @@ mod imp { let guard = STDERR_LOCK .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - let capture_fd = std::fs::File::create(path)?.into_raw_fd(); - let original_stderr_raw = unistd::dup(2)?; - unistd::dup2(capture_fd, 2)?; - unistd::close(capture_fd)?; - let original_stderr = unsafe { OwnedFd::from_raw_fd(original_stderr_raw) }; + let capture_file = std::fs::File::create(path)?; + let original_stderr = + OwnedFd::from(unistd::dup(2).map(|fd| unsafe { OwnedFd::from_raw_fd(fd) })?); + unistd::dup2(capture_file.as_raw_fd(), 2)?; + // capture_file dropped here — its OwnedFd closes the fd via RAII Ok(Self { - original_stderr, + original_stderr: Some(original_stderr), _guard: guard, }) } pub fn restore(self) -> Result<()> { - unistd::dup2(self.original_stderr.as_raw_fd(), 2)?; - // _guard is dropped here, releasing STDERR_LOCK + if let Some(ref fd) = self.original_stderr { + unistd::dup2(fd.as_raw_fd(), 2)?; + } + // Drop restores via Drop impl if restore() wasn't called, + // but explicit call lets caller handle errors. Ok(()) } } + + impl Drop for Capture { + fn drop(&mut self) { + if let Some(ref fd) = self.original_stderr.take() { + let _ = unistd::dup2(fd.as_raw_fd(), 2); + } + } + } } #[cfg(windows)] From 341059670fdb3e76cebb88879078c072a29dfc6d Mon Sep 17 00:00:00 2001 From: danbugs Date: Fri, 15 May 2026 06:04:57 +0000 Subject: [PATCH 6/6] fix: remove useless OwnedFd::from conversion (clippy) Signed-off-by: danbugs --- host/src/stderr_capture.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/host/src/stderr_capture.rs b/host/src/stderr_capture.rs index 73339b9..f6688f7 100644 --- a/host/src/stderr_capture.rs +++ b/host/src/stderr_capture.rs @@ -28,8 +28,7 @@ mod imp { .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); let capture_file = std::fs::File::create(path)?; - let original_stderr = - OwnedFd::from(unistd::dup(2).map(|fd| unsafe { OwnedFd::from_raw_fd(fd) })?); + let original_stderr = unistd::dup(2).map(|fd| unsafe { OwnedFd::from_raw_fd(fd) })?; unistd::dup2(capture_file.as_raw_fd(), 2)?; // capture_file dropped here — its OwnedFd closes the fd via RAII Ok(Self {