From 36f513196082b9b9a85c17958e7daa501dbf2218 Mon Sep 17 00:00:00 2001 From: Kandelo Agent Date: Mon, 15 Jun 2026 05:57:12 +0000 Subject: [PATCH 1/7] fix: raise SIGPIPE for bridged TCP EPIPE (cherry picked from commit aede00ba912f3dff9b8fdc31aa467bc40da2f5c5) --- crates/kernel/src/syscalls.rs | 98 +++++++++++++++++++++++++++++++---- 1 file changed, 87 insertions(+), 11 deletions(-) diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index 175960c9a..7730d22e3 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -2680,9 +2680,19 @@ pub fn sys_write( return Err(Errno::EAGAIN); } } - // External path: delegate to host + // External path: delegate to host. A stream write that + // fails with EPIPE must also generate SIGPIPE; the host + // only reports the errno, so mirror the POSIX side effect + // in the kernel just like the in-kernel pipe-backed TCP + // path above. let net_handle = sock.host_net_handle.ok_or(Errno::ENOTCONN)?; - host.host_net_send(net_handle, buf, 0) + match host.host_net_send(net_handle, buf, 0) { + Err(Errno::EPIPE) => { + proc.signals.raise(wasm_posix_shared::signal::SIGPIPE); + Err(Errno::EPIPE) + } + other => other, + } } SocketDomain::Unix => { if sock.send_buf_idx.is_none() @@ -6677,10 +6687,26 @@ pub fn sys_send( SocketDomain::Inet | SocketDomain::Inet6 => { // Loopback path: use pipe buffers if available if sock.send_buf_idx.is_some() { - return sys_write(proc, host, fd, buf); + let nosignal = flags & MSG_NOSIGNAL != 0; + let sigpipe_was_pending = + proc.signals.is_pending(wasm_posix_shared::signal::SIGPIPE); + let result = sys_write(proc, host, fd, buf); + // MSG_NOSIGNAL: suppress SIGPIPE raised by write. + if nosignal && !sigpipe_was_pending { + proc.signals.clear(wasm_posix_shared::signal::SIGPIPE); + } + return result; } let net_handle = sock.host_net_handle.ok_or(Errno::ENOTCONN)?; - host.host_net_send(net_handle, buf, flags) + match host.host_net_send(net_handle, buf, flags) { + Err(Errno::EPIPE) => { + if flags & MSG_NOSIGNAL == 0 { + proc.signals.raise(wasm_posix_shared::signal::SIGPIPE); + } + Err(Errno::EPIPE) + } + other => other, + } } SocketDomain::Unix => { // DGRAM bit-bucket (syslog pattern): data is discarded @@ -11086,6 +11112,11 @@ mod tests { /// Override for `gl_submit`'s return value (0 = success, negative /// = errno). Defaults to 0. gl_submit_rc: i32, + net_connect_result: Result<(), Errno>, + net_connect_status_result: Result<(), Errno>, + net_send_result: Result, + net_connect_calls: Vec<(i32, Vec, u16)>, + net_listen_calls: Vec<(i32, u16, [u8; 4])>, } impl MockHostIO { @@ -11109,6 +11140,11 @@ mod tests { gl_unbind_calls: Vec::new(), gbm_bo_bind_rc: 0, gl_submit_rc: 0, + net_connect_result: Err(Errno::ECONNREFUSED), + net_connect_status_result: Err(Errno::ECONNREFUSED), + net_send_result: Err(Errno::ENOTCONN), + net_connect_calls: Vec::new(), + net_listen_calls: Vec::new(), } } @@ -11465,14 +11501,15 @@ mod tests { } fn host_net_connect( &mut self, - _handle: i32, - _addr: &[u8], - _port: u16, + handle: i32, + addr: &[u8], + port: u16, ) -> Result<(), Errno> { - Err(Errno::ECONNREFUSED) + self.net_connect_calls.push((handle, addr.to_vec(), port)); + self.net_connect_result } fn host_net_connect_status(&mut self, _handle: i32) -> Result<(), Errno> { - Err(Errno::ECONNREFUSED) + self.net_connect_status_result } fn host_net_send( &mut self, @@ -11480,7 +11517,7 @@ mod tests { _data: &[u8], _flags: u32, ) -> Result { - Err(Errno::ENOTCONN) + self.net_send_result } fn host_net_recv( &mut self, @@ -11494,7 +11531,8 @@ mod tests { fn host_net_close(&mut self, _handle: i32) -> Result<(), Errno> { Ok(()) } - fn host_net_listen(&mut self, _fd: i32, _port: u16, _addr: &[u8; 4]) -> Result<(), Errno> { + fn host_net_listen(&mut self, fd: i32, port: u16, addr: &[u8; 4]) -> Result<(), Errno> { + self.net_listen_calls.push((fd, port, *addr)); Ok(()) } fn host_getaddrinfo(&mut self, _name: &[u8], _result: &mut [u8]) -> Result { @@ -13450,6 +13488,25 @@ mod tests { assert!(proc.signals.is_pending(wasm_posix_shared::signal::SIGPIPE)); } + #[test] + fn test_write_external_tcp_epipe_raises_sigpipe() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + use wasm_posix_shared::socket::*; + + host.net_connect_result = Ok(()); + host.net_connect_status_result = Ok(()); + host.net_send_result = Err(Errno::EPIPE); + + let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + let addr = [2, 0, 0, 80, 93, 184, 216, 34, 0, 0, 0, 0, 0, 0, 0, 0]; + sys_connect(&mut proc, &mut host, fd, &addr).unwrap(); + + let result = sys_write(&mut proc, &mut host, fd, b"test"); + assert_eq!(result, Err(Errno::EPIPE)); + assert!(proc.signals.is_pending(wasm_posix_shared::signal::SIGPIPE)); + } + #[test] fn test_socketpair_close_both_frees_pipes() { let mut proc = Process::new(1); @@ -13658,6 +13715,25 @@ mod tests { assert!(!proc.signals.is_pending(wasm_posix_shared::signal::SIGPIPE)); } + #[test] + fn test_send_external_tcp_msg_nosignal_suppresses_sigpipe() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + use wasm_posix_shared::socket::*; + + host.net_connect_result = Ok(()); + host.net_connect_status_result = Ok(()); + host.net_send_result = Err(Errno::EPIPE); + + let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + let addr = [2, 0, 0, 80, 93, 184, 216, 34, 0, 0, 0, 0, 0, 0, 0, 0]; + sys_connect(&mut proc, &mut host, fd, &addr).unwrap(); + + let result = sys_send(&mut proc, &mut host, fd, b"test", MSG_NOSIGNAL); + assert_eq!(result, Err(Errno::EPIPE)); + assert!(!proc.signals.is_pending(wasm_posix_shared::signal::SIGPIPE)); + } + #[test] fn test_send_not_connected() { let mut proc = Process::new(1); From 263c74ceed8e3dcd361d1609d66f004d80d13b47 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Fri, 10 Jul 2026 12:58:25 -0400 Subject: [PATCH 2/7] fix: cover default-flags TCP EPIPE SIGPIPE and document the behavior The SIGPIPE-on-EPIPE change added a test for external send with MSG_NOSIGNAL (signal suppressed) but not for external send with default flags, which is the main new branch in sys_send. Add that case, and record the observable behavior in docs/posix-status.md: a send to a broken stream peer returns EPIPE and raises SIGPIPE across host-bridged external and loopback TCP, with MSG_NOSIGNAL suppressing the signal. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/kernel/src/syscalls.rs | 21 +++++++++++++++++++++ docs/posix-status.md | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index 7730d22e3..dfff0229f 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -13734,6 +13734,27 @@ mod tests { assert!(!proc.signals.is_pending(wasm_posix_shared::signal::SIGPIPE)); } + #[test] + fn test_send_external_tcp_epipe_raises_sigpipe() { + // Default flags (no MSG_NOSIGNAL): an EPIPE from the host bridge on an + // external TCP send must raise SIGPIPE, mirroring sys_write's path. + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + use wasm_posix_shared::socket::*; + + host.net_connect_result = Ok(()); + host.net_connect_status_result = Ok(()); + host.net_send_result = Err(Errno::EPIPE); + + let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + let addr = [2, 0, 0, 80, 93, 184, 216, 34, 0, 0, 0, 0, 0, 0, 0, 0]; + sys_connect(&mut proc, &mut host, fd, &addr).unwrap(); + + let result = sys_send(&mut proc, &mut host, fd, b"test", 0); + assert_eq!(result, Err(Errno::EPIPE)); + assert!(proc.signals.is_pending(wasm_posix_shared::signal::SIGPIPE)); + } + #[test] fn test_send_not_connected() { let mut proc = Process::new(1); diff --git a/docs/posix-status.md b/docs/posix-status.md index dbec136ee..3e048a5b3 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -233,7 +233,7 @@ shortcuts. | `listen()` | Partial | AF_INET TCP delegates to the active HostIO networking backend, including Node `net` and the browser local virtual-network backend. Datagram listen rejects as unsupported. AF_UNIX stream listen remains EOPNOTSUPP. | | `accept()` / `accept4()` | Partial | AF_INET TCP delegates to the active HostIO networking backend and returns connected sockets. Datagram accept rejects as unsupported. SOCK_NONBLOCK and SOCK_CLOEXEC flags on accept4. | | `connect()` | Partial | AF_UNIX SOCK_DGRAM connects to a bit-bucket pattern. AF_INET TCP is host-backed and works over Node external TCP or the browser local virtual-network backend. AF_INET UDP connect stores the peer, auto-binds an ephemeral local port when needed, filters receives to the connected peer, and supports AF_UNSPEC unconnect. UDP loopback and local virtual routes are supported; external raw UDP routes return ENETUNREACH without a HostIO UDP proxy/backend. | -| `send()` / `recv()` | Partial | Unix domain streams, AF_INET TCP, and connected AF_INET UDP. TCP send/recv works over Node external TCP and the local virtual-network backend. UDP preserves datagram boundaries and supports MSG_PEEK/MSG_DONTWAIT through recvfrom. MSG_NOSIGNAL is honored for stream/broken-pipe paths. | +| `send()` / `recv()` | Partial | Unix domain streams, AF_INET TCP, and connected AF_INET UDP. TCP send/recv works over Node external TCP and the local virtual-network backend. UDP preserves datagram boundaries and supports MSG_PEEK/MSG_DONTWAIT through recvfrom. A send to a stream socket whose peer has closed returns EPIPE and raises SIGPIPE, including host-bridged (external and loopback) TCP; MSG_NOSIGNAL suppresses the signal while still returning EPIPE. | | `sendto()` / `recvfrom()` | Partial | AF_INET UDP loopback and local virtual-network send/receive, connected and unconnected sendto, source address reporting, and connected receive filtering are implemented. External raw UDP routes return ENETUNREACH and are narrowly xfailed in the Sortix UDP suite. | | `setsockopt()` / `getsockopt()` | Partial | SOL_SOCKET: SO_TYPE, SO_DOMAIN, SO_ERROR, SO_ACCEPTCONN, SO_RCVBUF, SO_SNDBUF readable; SO_REUSEADDR affects UDP bind conflicts; SO_KEEPALIVE, SO_LINGER, SO_RCVTIMEO, SO_SNDTIMEO, SO_BROADCAST accepted/stored. IPPROTO_TCP: TCP_NODELAY stored. | | `shutdown()` | Partial | SHUT_RD, SHUT_WR, SHUT_RDWR for stream sockets and UDP readiness/error behavior. UDP write shutdown returns EPIPE on datagram send; read shutdown is EOF-like for recv/poll. | From f5cb113bca19f06ed1f8e693e6c7321a7bddfc5e Mon Sep 17 00:00:00 2001 From: Kandelo Agent Date: Mon, 15 Jun 2026 17:02:26 +0000 Subject: [PATCH 3/7] fix: reject unsupported mremap flags (cherry picked from commit 8c015fab1edd9849040c2d1ed2e4c6447baf8048) (cherry picked from commit ccf8d32e93995aad851be83341193ae27bed5c1f) --- crates/kernel/src/syscalls.rs | 32 ++++++++++++++++++++++++++++++++ crates/kernel/src/wasm_api.rs | 3 ++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index dfff0229f..4f963218f 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -5605,10 +5605,19 @@ pub fn sys_mremap( flags: u32, ) -> Result { const MREMAP_MAYMOVE: u32 = 1; + const SUPPORTED_FLAGS: u32 = MREMAP_MAYMOVE; if old_len == 0 || new_len == 0 { return Err(Errno::EINVAL); } + if flags & !SUPPORTED_FLAGS != 0 { + // The current wasm libc import ABI passes the Linux mremap syscall's + // first four arguments. MREMAP_FIXED requires the fifth new-address + // argument, and MREMAP_DONTUNMAP has Linux-specific aliasing semantics + // that the kernel does not implement. Reject unsupported flags + // explicitly instead of silently treating them as an ordinary remap. + return Err(Errno::EINVAL); + } // Page-align both sizes let aligned_old = (old_len + 0xFFFF) & !0xFFFF; @@ -19854,6 +19863,29 @@ mod tests { // in `host/src/kernel-worker.ts`'s SYS_MREMAP post-syscall fixup. } + #[test] + fn test_mremap_rejects_unsupported_flags() { + let mut proc = Process::new(1); + use wasm_posix_shared::mmap::{MAP_ANONYMOUS, MAP_PRIVATE, PROT_READ, PROT_WRITE}; + let addr = proc.memory.mmap_anonymous( + 0, + 0x10000, + PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, + ); + + // Linux MREMAP_FIXED requires the fifth syscall argument (new_addr), + // which the current wasm libc import ABI does not pass through. The + // kernel must reject it rather than accidentally treating the request + // as an in-place grow at old_addr. + assert_eq!( + sys_mremap(&mut proc, addr, 0x10000, 0x20000, 2).unwrap_err(), + Errno::EINVAL + ); + assert!(proc.memory.is_mapped(addr)); + assert!(!proc.memory.is_mapped(addr + 0x10000)); + } + // ---- O_CLOFORK / FD_CLOFORK tests ---- #[test] diff --git a/crates/kernel/src/wasm_api.rs b/crates/kernel/src/wasm_api.rs index 93f9c8ad9..818bfe6d4 100644 --- a/crates/kernel/src/wasm_api.rs +++ b/crates/kernel/src/wasm_api.rs @@ -6037,7 +6037,8 @@ pub extern "C" fn kernel_utimensat( result } -/// Remap memory. Returns MAP_FAILED (-1 as u32) since Wasm doesn't support this. +/// Remap memory. Supports in-place resize and MREMAP_MAYMOVE; unsupported +/// Linux-specific flag combinations are rejected by sys_mremap with EINVAL. #[unsafe(no_mangle)] pub extern "C" fn kernel_mremap( old_addr: usize, From ece2f3e78d69aced1ff350ff49af5d7aaace7479 Mon Sep 17 00:00:00 2001 From: Kandelo Agent Date: Tue, 16 Jun 2026 02:00:02 +0000 Subject: [PATCH 4/7] fix: preserve unix socket path ownership in stat (cherry picked from commit 80396cd2653afe67160cecd1c2fbb1348725b9e6) (cherry picked from commit c40c493e708a84cbfe1e8d2cbbf4f0032f4e8bec) --- crates/kernel/src/syscalls.rs | 105 ++++++++++++++-------------------- 1 file changed, 43 insertions(+), 62 deletions(-) diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index 4f963218f..8cd8f0d44 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -4020,6 +4020,34 @@ fn match_pty_stat(resolved: &[u8], uid: u32, gid: u32) -> Option { } } +fn unix_socket_path_stat( + proc: &Process, + host: &mut dyn HostIO, + resolved: &[u8], + follow: bool, +) -> Result, Errno> { + let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; + if !registry.contains(resolved) { + return Ok(None); + } + + // Filesystem-backed AF_UNIX sockets have ordinary path metadata. The + // socket registry tells Kandelo that the path should report S_IFSOCK, but + // uid/gid/mode/timestamps still come from the underlying VFS inode so + // chown(2), chmod(2), and stat(2) round-trip like a POSIX socket node. + check_search_path(proc, host, resolved)?; + let mut st = if follow { + host.host_stat(resolved)? + } else { + host.host_lstat(resolved)? + }; + st.st_mode = wasm_posix_shared::mode::S_IFSOCK | (st.st_mode & 0o7777); + if st.st_nlink == 0 { + st.st_nlink = 1; + } + Ok(Some(st)) +} + pub fn sys_stat(proc: &mut Process, host: &mut dyn HostIO, path: &[u8]) -> Result { let resolved = resolve_path(path, &proc.cwd); if let Some(dev) = match_virtual_device(&resolved) { @@ -4056,27 +4084,8 @@ pub fn sys_stat(proc: &mut Process, host: &mut dyn HostIO, path: &[u8]) -> Resul if let Some(st) = synthetic_file_stat(&resolved, proc.euid, proc.egid) { return Ok(st); } - // Check Unix socket registry - { - let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; - if registry.contains(&resolved) { - return Ok(WasmStat { - st_dev: 0, - st_ino: 0x554E5800, // "UNX\0" - st_mode: wasm_posix_shared::mode::S_IFSOCK | 0o755, - st_nlink: 1, - st_uid: proc.euid, - st_gid: proc.egid, - st_size: 0, - st_atime_sec: 0, - st_atime_nsec: 0, - st_mtime_sec: 0, - st_mtime_nsec: 0, - st_ctime_sec: 0, - st_ctime_nsec: 0, - _pad: 0, - }); - } + if let Some(st) = unix_socket_path_stat(proc, host, &resolved, true)? { + return Ok(st); } // VFS is the source of truth for ownership: host_stat already returns the // file's real uid/gid, so just propagate. @@ -4124,27 +4133,8 @@ pub fn sys_lstat( if let Some(st) = synthetic_file_stat(&resolved, proc.euid, proc.egid) { return Ok(st); } - // Check Unix socket registry - { - let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; - if registry.contains(&resolved) { - return Ok(WasmStat { - st_dev: 0, - st_ino: 0x554E5800, // "UNX\0" - st_mode: wasm_posix_shared::mode::S_IFSOCK | 0o755, - st_nlink: 1, - st_uid: proc.euid, - st_gid: proc.egid, - st_size: 0, - st_atime_sec: 0, - st_atime_nsec: 0, - st_mtime_sec: 0, - st_mtime_nsec: 0, - st_ctime_sec: 0, - st_ctime_nsec: 0, - _pad: 0, - }); - } + if let Some(st) = unix_socket_path_stat(proc, host, &resolved, false)? { + return Ok(st); } // VFS is the source of truth for ownership: host_lstat already returns the // link's real uid/gid, so just propagate. @@ -8290,27 +8280,10 @@ pub fn sys_fstatat( if let Some(st) = synthetic_file_stat(&resolved, proc.euid, proc.egid) { return Ok(st); } - // Check Unix socket registry + if let Some(st) = + unix_socket_path_stat(proc, host, &resolved, flags & AT_SYMLINK_NOFOLLOW == 0)? { - let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; - if registry.contains(&resolved) { - return Ok(WasmStat { - st_dev: 0, - st_ino: 0x554E5800, // "UNX\0" - st_mode: wasm_posix_shared::mode::S_IFSOCK | 0o755, - st_nlink: 1, - st_uid: proc.euid, - st_gid: proc.egid, - st_size: 0, - st_atime_sec: 0, - st_atime_nsec: 0, - st_mtime_sec: 0, - st_mtime_nsec: 0, - st_ctime_sec: 0, - st_ctime_nsec: 0, - _pad: 0, - }); - } + return Ok(st); } // VFS is the source of truth for ownership: host_stat / host_lstat // already return the real uid/gid, so just propagate. @@ -16805,6 +16778,14 @@ mod tests { st.st_mode & wasm_posix_shared::mode::S_IFMT, wasm_posix_shared::mode::S_IFSOCK ); + sys_chown(&mut proc, &mut host, b"/tmp/stat.sock", 1234, 5678).unwrap(); + let st = sys_stat(&mut proc, &mut host, b"/tmp/stat.sock").unwrap(); + assert_eq!(st.st_uid, 1234); + assert_eq!(st.st_gid, 5678); + assert_eq!( + st.st_mode & wasm_posix_shared::mode::S_IFMT, + wasm_posix_shared::mode::S_IFSOCK + ); // Cleanup let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; From 1ba3aa71db4a6f34172a921b88b5163f1ab90e3c Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Fri, 10 Jul 2026 13:56:43 -0400 Subject: [PATCH 5/7] fix: apply umask to pathname socket metadata --- crates/kernel/src/syscalls.rs | 71 ++++++++++++++++++++++++++++------- docs/posix-status.md | 4 +- 2 files changed, 59 insertions(+), 16 deletions(-) diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index 8cd8f0d44..1b760b78c 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -4042,9 +4042,6 @@ fn unix_socket_path_stat( host.host_lstat(resolved)? }; st.st_mode = wasm_posix_shared::mode::S_IFSOCK | (st.st_mode & 0o7777); - if st.st_nlink == 0 { - st.st_nlink = 1; - } Ok(Some(st)) } @@ -7098,7 +7095,16 @@ pub fn sys_bind( // rebased branch.) use wasm_posix_shared::flags::{O_CREAT, O_EXCL, O_WRONLY}; check_open_permissions(proc, host, &resolved, O_CREAT | O_EXCL | O_WRONLY)?; - let h = match host.host_open(&resolved, O_CREAT | O_EXCL | O_WRONLY, 0o600) { + // Linux pathname sockets start with every permission bit enabled, + // filtered through the creating process's umask. The backing VFS + // inode owns these bits so later chmod/stat operations observe one + // authoritative value. + let socket_mode = 0o777 & !proc.umask; + let h = match host.host_open( + &resolved, + O_CREAT | O_EXCL | O_WRONLY, + socket_mode, + ) { Ok(h) => h, Err(Errno::EEXIST) => return Err(Errno::EADDRINUSE), Err(e) => return Err(e), @@ -16762,10 +16768,21 @@ mod tests { #[test] fn test_stat_unix_socket_path() { + use wasm_posix_shared::flags::AT_FDCWD; + use wasm_posix_shared::mode::{S_IFMT, S_IFSOCK}; + + fn assert_socket_metadata(st: WasmStat, mode: u32, uid: u32, gid: u32) { + assert_eq!(st.st_mode & S_IFMT, S_IFSOCK); + assert_eq!(st.st_mode & 0o7777, mode); + assert_eq!(st.st_uid, uid); + assert_eq!(st.st_gid, gid); + } + let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); let mut proc = Process::new(1); let mut host = MockHostIO::new(); proc.pid = 9020; + proc.umask = 0o027; let fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); let mut addr = [0u8; 110]; addr[0] = 1; @@ -16773,18 +16790,44 @@ mod tests { addr[2..2 + path.len()].copy_from_slice(path); sys_bind(&mut proc, &mut host, fd, &addr[..2 + path.len() + 1]).unwrap(); - let st = sys_stat(&mut proc, &mut host, b"/tmp/stat.sock").unwrap(); - assert_eq!( - st.st_mode & wasm_posix_shared::mode::S_IFMT, - wasm_posix_shared::mode::S_IFSOCK + assert_socket_metadata( + sys_stat(&mut proc, &mut host, path).unwrap(), + 0o750, + 0, + 0, ); + assert_socket_metadata( + sys_lstat(&mut proc, &mut host, path).unwrap(), + 0o750, + 0, + 0, + ); + assert_socket_metadata( + sys_fstatat(&mut proc, &mut host, AT_FDCWD, path, 0).unwrap(), + 0o750, + 0, + 0, + ); + + sys_chmod(&mut proc, &mut host, path, 0o640).unwrap(); sys_chown(&mut proc, &mut host, b"/tmp/stat.sock", 1234, 5678).unwrap(); - let st = sys_stat(&mut proc, &mut host, b"/tmp/stat.sock").unwrap(); - assert_eq!(st.st_uid, 1234); - assert_eq!(st.st_gid, 5678); - assert_eq!( - st.st_mode & wasm_posix_shared::mode::S_IFMT, - wasm_posix_shared::mode::S_IFSOCK + assert_socket_metadata( + sys_stat(&mut proc, &mut host, path).unwrap(), + 0o640, + 1234, + 5678, + ); + assert_socket_metadata( + sys_lstat(&mut proc, &mut host, path).unwrap(), + 0o640, + 1234, + 5678, + ); + assert_socket_metadata( + sys_fstatat(&mut proc, &mut host, AT_FDCWD, path, 0).unwrap(), + 0o640, + 1234, + 5678, ); // Cleanup diff --git a/docs/posix-status.md b/docs/posix-status.md index 3e048a5b3..b42535f96 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -193,7 +193,7 @@ Kandelo uses a single kernel Wasm instance that holds a `ProcessTable` and serve | `chdir()` / `getcwd()` | Partial | Kernel-maintained cwd. chdir validates via host_stat that target is S_IFDIR. getcwd returns ERANGE if buffer too small. | | `link()` / `unlink()` | Partial | Host-delegated. Relative paths resolved via kernel cwd. | | `rename()` | Partial | Host-delegated. Both paths resolved via kernel cwd. | -| `stat()` / `lstat()` | Partial | Host-delegated. stat follows symlinks, lstat does not. | +| `stat()` / `lstat()` | Partial | Host-delegated. stat follows symlinks, lstat does not. Registered AF_UNIX pathname sockets preserve the backing VFS inode's uid, gid, permissions, timestamps, and link count while reporting `S_IFSOCK`. | | `chmod()` / `chown()` | Partial | VFS metadata updates. Node host-backed files receive native mode only at file/directory creation; later mode changes and all ownership changes stay virtual. Browser memory-backed mounts store metadata in the VFS. | | `access()` | Partial | Host-delegated. Checks real filesystem permissions. | | `realpath()` | Full | Resolves path against cwd, normalizes `.`/`..`, resolves symlinks via iterative lstat/readlink (ELOOP after 40 resolutions), verifies existence. | @@ -229,7 +229,7 @@ shortcuts. |----------|--------|-------| | `socket()` | Partial | AF_UNIX and AF_INET supported for SOCK_STREAM and SOCK_DGRAM. SOCK_NONBLOCK and SOCK_CLOEXEC flags handled. AF_INET SOCK_DGRAM is implemented as a kernel-level datagram socket for loopback/virtual routes; external raw UDP is not exposed directly to userspace. | | `socketpair()` | Full | AF_UNIX SOCK_STREAM. Bidirectional ring buffers (64KB each). Returns pre-connected pair. | -| `bind()` | Partial | AF_UNIX paths, AF_INET TCP host-backed bind/listen, and AF_INET UDP in-kernel bind for INADDR_ANY, loopback, and broadcast addresses. The browser local virtual-network backend supports AF_INET TCP/UDP binds between attached Kandelo machines. UDP ephemeral ports, getsockname, bind conflicts, and SO_REUSEADDR conflict cases are covered by Sortix UDP tests. | +| `bind()` | Partial | AF_UNIX paths, AF_INET TCP host-backed bind/listen, and AF_INET UDP in-kernel bind for INADDR_ANY, loopback, and broadcast addresses. AF_UNIX pathname bind creates a VFS inode with mode `0777 & ~umask`; while the path remains registered, `stat`/`lstat`/`fstatat`, `chmod`, and `chown` share that inode metadata. Registry state does not yet follow pathname hard links or renames, and process cleanup can leave an ordinary-file placeholder behind. The browser local virtual-network backend supports AF_INET TCP/UDP binds between attached Kandelo machines. UDP ephemeral ports, getsockname, bind conflicts, and SO_REUSEADDR conflict cases are covered by Sortix UDP tests. | | `listen()` | Partial | AF_INET TCP delegates to the active HostIO networking backend, including Node `net` and the browser local virtual-network backend. Datagram listen rejects as unsupported. AF_UNIX stream listen remains EOPNOTSUPP. | | `accept()` / `accept4()` | Partial | AF_INET TCP delegates to the active HostIO networking backend and returns connected sockets. Datagram accept rejects as unsupported. SOCK_NONBLOCK and SOCK_CLOEXEC flags on accept4. | | `connect()` | Partial | AF_UNIX SOCK_DGRAM connects to a bit-bucket pattern. AF_INET TCP is host-backed and works over Node external TCP or the browser local virtual-network backend. AF_INET UDP connect stores the peer, auto-binds an ephemeral local port when needed, filters receives to the connected peer, and supports AF_UNSPEC unconnect. UDP loopback and local virtual routes are supported; external raw UDP routes return ENETUNREACH without a HostIO UDP proxy/backend. | From 1be3891590b2388f40d8b5e07a49831898127302 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Fri, 19 Jun 2026 01:58:17 -0400 Subject: [PATCH 6/7] fix: improve PHP procfs and utility coverage (cherry picked from commit 7e54390635a03ca24acba7d20dae05596ddeafa2) --- crates/kernel/src/procfs.rs | 34 ++- .../posix-utils-lite/src/posix-utils-lite.c | 196 ++++++++++++++++-- 2 files changed, 205 insertions(+), 25 deletions(-) diff --git a/crates/kernel/src/procfs.rs b/crates/kernel/src/procfs.rs index 84844e3d6..489428824 100644 --- a/crates/kernel/src/procfs.rs +++ b/crates/kernel/src/procfs.rs @@ -285,9 +285,16 @@ pub fn generate_stat(proc: &Process) -> Vec { // pid (comm) state ppid pgrp session tty_nr tpgid flags // minflt cminflt majflt cmajflt utime stime cutime cstime // priority nice num_threads itrealvalue starttime vsize rss ... + // + // Keep both scheduling fields distinct. Several user-space tools (ps, + // procps-compatible libraries, PHP's proc_nice() tests) read the nice + // value from field 19; field 18 is the scheduler priority. Kandelo does + // not have a host CPU scheduler, but exposing the stored POSIX nice value + // in the Linux-compatible procfs slot is observable process metadata. + let priority = 20 + proc.nice; let line = format!( - "{} ({}) {} {} {} {} 0 0 0 0 0 0 0 0 0 0 {} 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n", - proc.pid, name, state, proc.ppid, proc.pgid, proc.sid, proc.nice, + "{} ({}) {} {} {} {} 0 0 0 0 0 0 0 0 0 0 0 {} {} 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n", + proc.pid, name, state, proc.ppid, proc.pgid, proc.sid, priority, proc.nice, ); line.into_bytes() } @@ -439,6 +446,7 @@ pub fn procfs_stat(entry: &ProcfsEntry, content_size: u64, follow_symlinks: bool st_ctime_sec: 0, st_ctime_nsec: 0, _pad: 0, + ..WasmStat::default() }; } @@ -459,6 +467,7 @@ pub fn procfs_stat(entry: &ProcfsEntry, content_size: u64, follow_symlinks: bool st_ctime_sec: 0, st_ctime_nsec: 0, _pad: 0, + ..WasmStat::default() }; } @@ -479,6 +488,7 @@ pub fn procfs_stat(entry: &ProcfsEntry, content_size: u64, follow_symlinks: bool st_ctime_sec: 0, st_ctime_nsec: 0, _pad: 0, + ..WasmStat::default() } } @@ -657,6 +667,21 @@ fn validate_pid(proc: &Process, pid: u32) -> Result<(), Errno> { Err(Errno::ENOENT) } +/// Validate that a parsed procfs entry names an existing process. +/// +/// Path parsing alone is not existence: Linux procfs only exposes +/// `/proc//...` while that pid has a process-table entry. Callers that +/// service metadata-only operations (stat/access/chdir) must perform the same +/// validation as `procfs_open`, otherwise probes such as +/// `test -r /proc/123/stat` incorrectly succeed for already-reaped pids. +pub fn validate_entry(proc: &Process, entry: &ProcfsEntry) -> Result<(), Errno> { + let pid = entry_pid(entry); + if pid != 0 { + validate_pid(proc, pid)?; + } + Ok(()) +} + /// Allocate a procfs buffer slot, reusing freed slots. fn alloc_procfs_buf(proc: &mut Process, data: Vec) -> usize { for (i, slot) in proc.procfs_bufs.iter().enumerate() { @@ -1046,7 +1071,10 @@ mod tests { let stat = generate_stat(&proc); let stat_str = core::str::from_utf8(&stat).unwrap(); assert!(stat_str.starts_with("42 (test_program) R 1 42 1")); - assert!(stat_str.contains(" 5 ")); // nice value + let after_comm = stat_str.split(") ").nth(1).unwrap(); + let mut fields = after_comm.split_whitespace(); + assert_eq!(fields.nth(15).unwrap(), "25"); // field 18: scheduler priority + assert_eq!(fields.next().unwrap(), "5"); // field 19: nice value } #[test] diff --git a/packages/registry/posix-utils-lite/src/posix-utils-lite.c b/packages/registry/posix-utils-lite/src/posix-utils-lite.c index 505c975ac..63d210b43 100644 --- a/packages/registry/posix-utils-lite/src/posix-utils-lite.c +++ b/packages/registry/posix-utils-lite/src/posix-utils-lite.c @@ -415,11 +415,172 @@ static int util_logger(int argc, char **argv) { return 0; } +struct ps_fields { + int pid; + int nice; + int command; +}; + +static int ps_pid_selected(long pid, long *pids, int npids) { + if (npids == 0) { + return 1; + } + for (int i = 0; i < npids; i++) { + if (pids[i] == pid) { + return 1; + } + } + return 0; +} + +static void ps_add_pid_list(const char *arg, long *pids, int *npids, int max_pids) { + const char *p = arg; + while (*p && *npids < max_pids) { + char *end = NULL; + long pid = strtol(p, &end, 10); + if (end == p) { + break; + } + pids[(*npids)++] = pid; + p = (*end == ',') ? end + 1 : end; + } +} + +static void ps_parse_fields(const char *arg, struct ps_fields *fields) { + fields->pid = 0; + fields->nice = 0; + fields->command = 0; + + char *copy = xstrdup(arg); + for (char *tok = strtok(copy, ", "); tok; tok = strtok(NULL, ", ")) { + if (streq(tok, "pid")) { + fields->pid = 1; + } else if (streq(tok, "nice") || streq(tok, "ni")) { + fields->nice = 1; + } else if (streq(tok, "comm") || streq(tok, "command") || + streq(tok, "args")) { + fields->command = 1; + } + } + if (!fields->pid && !fields->nice && !fields->command) { + fields->pid = 1; + fields->command = 1; + } + free(copy); +} + +static int ps_read_stat(long pid, char *comm, size_t comm_len, long *nice_value) { + char path[PATH_MAX]; + snprintf(path, sizeof(path), "/proc/%ld/stat", pid); + FILE *f = fopen(path, "r"); + if (!f) { + return -1; + } + char line[1024]; + if (!fgets(line, sizeof(line), f)) { + fclose(f); + return -1; + } + fclose(f); + + char *open = strchr(line, '('); + char *close = strrchr(line, ')'); + if (!open || !close || close <= open) { + return -1; + } + size_t n = (size_t)(close - open - 1); + if (n >= comm_len) { + n = comm_len - 1; + } + memcpy(comm, open + 1, n); + comm[n] = '\0'; + + // Fields after comm begin with field 3 (state). Nice is field 19, so it + // is token index 16 in this suffix. This follows Linux /proc//stat + // and matches the kernel procfs generator. + char *suffix = close + 2; + char *save = NULL; + int index = 0; + for (char *tok = strtok_r(suffix, " \t\r\n", &save); tok; + tok = strtok_r(NULL, " \t\r\n", &save), index++) { + if (index == 16) { + if (nice_value) { + *nice_value = strtol(tok, NULL, 10); + } + return 0; + } + } + return -1; +} + +static void ps_read_cmdline(long pid, char *cmd, size_t cmd_len) { + char path[PATH_MAX]; + snprintf(path, sizeof(path), "/proc/%ld/cmdline", pid); + FILE *f = fopen(path, "rb"); + cmd[0] = '\0'; + if (!f) { + return; + } + size_t n = fread(cmd, 1, cmd_len - 1, f); + fclose(f); + for (size_t i = 0; i < n; i++) { + if (cmd[i] == '\0') { + cmd[i] = ' '; + } + } + cmd[n] = '\0'; +} + +static void ps_print_header(const struct ps_fields *fields) { + if (fields->pid) { + printf("%5s", "PID"); + } + if (fields->nice) { + printf("%s%4s", fields->pid ? " " : "", "NICE"); + } + if (fields->command) { + printf("%s%s", (fields->pid || fields->nice) ? " " : "", "COMMAND"); + } + putchar('\n'); +} + +static void ps_print_row(const struct ps_fields *fields, long pid, long nice_value, + const char *cmd) { + if (fields->pid) { + printf("%5ld", pid); + } + if (fields->nice) { + printf("%s%4ld", fields->pid ? " " : "", nice_value); + } + if (fields->command) { + printf("%s%s", (fields->pid || fields->nice) ? " " : "", cmd && cmd[0] ? cmd : "?"); + } + putchar('\n'); +} + static int util_ps(int argc, char **argv) { + long selected_pids[64]; + int nselected = 0; + struct ps_fields fields = {1, 0, 1}; + + for (int i = 1; i < argc; i++) { + if ((streq(argv[i], "-p") || streq(argv[i], "--pid")) && i + 1 < argc) { + ps_add_pid_list(argv[++i], selected_pids, &nselected, + (int)(sizeof(selected_pids) / sizeof(selected_pids[0]))); + } else if (strncmp(argv[i], "-p", 2) == 0 && argv[i][2]) { + ps_add_pid_list(argv[i] + 2, selected_pids, &nselected, + (int)(sizeof(selected_pids) / sizeof(selected_pids[0]))); + } else if ((streq(argv[i], "-o") || streq(argv[i], "--format")) && i + 1 < argc) { + ps_parse_fields(argv[++i], &fields); + } else if (strncmp(argv[i], "-o", 2) == 0 && argv[i][2]) { + ps_parse_fields(argv[i] + 2, &fields); + } + } + DIR *proc = opendir("/proc"); - puts(" PID COMMAND"); + ps_print_header(&fields); if (!proc) { - printf("%5ld %s\n", (long)getpid(), program_name(argv[0])); + ps_print_row(&fields, (long)getpid(), 0, program_name(argv[0])); return 0; } struct dirent *de; @@ -429,29 +590,20 @@ static int util_ps(int argc, char **argv) { if (!end || *end != '\0') { continue; } - char path[PATH_MAX]; - snprintf(path, sizeof(path), "/proc/%ld/cmdline", pid); - FILE *f = fopen(path, "rb"); - char cmd[256] = ""; - if (f) { - size_t n = fread(cmd, 1, sizeof(cmd) - 1, f); - fclose(f); - for (size_t i = 0; i < n; i++) { - if (cmd[i] == '\0') { - cmd[i] = ' '; - } - } - cmd[n] = '\0'; + if (!ps_pid_selected(pid, selected_pids, nselected)) { + continue; } + char cmd[256] = ""; + char comm[256] = ""; + long nice_value = 0; + ps_read_stat(pid, comm, sizeof(comm), &nice_value); if (cmd[0] == '\0') { - snprintf(path, sizeof(path), "/proc/%ld/stat", pid); - f = fopen(path, "r"); - if (f) { - fscanf(f, "%*d (%255[^)])", cmd); - fclose(f); - } + ps_read_cmdline(pid, cmd, sizeof(cmd)); + } + if (cmd[0] == '\0' && comm[0] != '\0') { + snprintf(cmd, sizeof(cmd), "%s", comm); } - printf("%5ld %s\n", pid, cmd[0] ? cmd : "?"); + ps_print_row(&fields, pid, nice_value, cmd); } closedir(proc); return 0; From 372387b15d0c190a8d8e8550ff8767de05e64a59 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Fri, 10 Jul 2026 14:24:15 -0400 Subject: [PATCH 7/7] fix: enforce truthful procfs process visibility Preserve PID scope for procfs net entries, distinguish PID 0 from global entries, and hide reaped limbo identities while retaining zombies. Validate process existence across metadata and readlink operations. Make ps skip vanished processes instead of fabricating nice values, rebuild the package at revision 3, and remove stale WasmStat defaults. --- crates/kernel/src/process_table.rs | 26 ++++- crates/kernel/src/procfs.rs | 108 +++++++++++++----- crates/kernel/src/syscalls.rs | 108 +++++++++++++++--- crates/kernel/src/wasm_api.rs | 4 +- docs/posix-status.md | 1 + .../build-posix-utils-lite.sh | 3 + packages/registry/posix-utils-lite/build.toml | 2 +- .../posix-utils-lite/src/posix-utils-lite.c | 75 +++++++++--- 8 files changed, 261 insertions(+), 66 deletions(-) diff --git a/crates/kernel/src/process_table.rs b/crates/kernel/src/process_table.rs index 6632161df..cbab8907f 100644 --- a/crates/kernel/src/process_table.rs +++ b/crates/kernel/src/process_table.rs @@ -846,11 +846,25 @@ impl ProcessTable { self.processes.get(&pid) } - /// Collect all active PIDs. + /// Collect every retained PID, including internal limbo identities. pub fn all_pids(&self) -> Vec { self.processes.keys().copied().collect() } + /// Collect PIDs that should be visible through procfs. + /// + /// Exited processes remain visible as zombies until their parent reaps + /// them. Limbo entries are already reaped and retained only as internal + /// process-group/session identities, so exposing them as `/proc/` + /// would resurrect a process that no longer exists. + pub fn procfs_pids(&self) -> Vec { + self.processes + .iter() + .filter(|(_, proc)| proc.state != ProcessState::Limbo) + .map(|(&pid, _)| pid) + .collect() + } + /// Collect PIDs of all processes in a given process group. pub fn pids_in_group(&self, pgid: u32) -> Vec { self.processes @@ -982,12 +996,22 @@ mod wait_tests { table.processes.get_mut(&102).unwrap().pgid = 101; table.processes.get_mut(&101).unwrap().state = ProcessState::Exited; + assert!( + table.procfs_pids().contains(&101), + "an unreaped zombie remains visible through procfs" + ); + table.reap_process(101).expect("reap group leader"); let limbo = table.get(101).expect("limbo leader retained"); assert_eq!(limbo.state, ProcessState::Limbo); assert_eq!(limbo.pgid, 101); assert_eq!(limbo.ppid, 100); + assert!(table.all_pids().contains(&101)); + assert!( + !table.procfs_pids().contains(&101), + "a reaped limbo identity must not remain visible through procfs" + ); table.processes.get_mut(&102).unwrap().state = ProcessState::Exited; table.reap_process(102).expect("reap final member"); diff --git a/crates/kernel/src/procfs.rs b/crates/kernel/src/procfs.rs index 489428824..14da1f697 100644 --- a/crates/kernel/src/procfs.rs +++ b/crates/kernel/src/procfs.rs @@ -63,9 +63,9 @@ pub enum ProcfsEntry { Cwd(u32), // /proc//cwd (symlink) Exe(u32), // /proc//exe (symlink) Root_(u32), // /proc//root (symlink) - NetDir, // /proc/net - NetTcp, // /proc/net/tcp - NetUnix, // /proc/net/unix + NetDir(Option), // /proc/net or /proc//net + NetTcp(Option), // /proc/net/tcp or /proc//net/tcp + NetUnix(Option), // /proc/net/unix or /proc//net/unix } impl ProcfsEntry { @@ -90,7 +90,7 @@ impl ProcfsEntry { | ProcfsEntry::PidDir(_) | ProcfsEntry::FdDir(_) | ProcfsEntry::FdInfoDir(_) - | ProcfsEntry::NetDir + | ProcfsEntry::NetDir(_) ) } } @@ -106,9 +106,32 @@ pub const MOUNTS_CONTENT: &[u8] = const MOUNTINFO_CONTENT: &[u8] = b"1 0 0:1 / / rw - kandelo-vfs kandelo-root rw\n2 1 0:2 / /proc rw,nosuid,nodev,noexec - proc proc rw,nosuid,nodev,noexec\n3 1 0:3 / /dev rw,nosuid - devfs devfs rw,nosuid\n"; -/// Extract the pid from a ProcfsEntry (0 for root/net entries). -pub fn entry_pid(entry: &ProcfsEntry) -> u32 { - entry_ids(entry).0 +/// Return the process scope of an entry, if it is under `/proc/`. +/// +/// `Option` is intentional: PID 0 is a process-scoped path that must be +/// validated and rejected, not a sentinel for a global procfs entry. +pub fn entry_pid(entry: &ProcfsEntry) -> Option { + match entry { + ProcfsEntry::PidDir(pid) + | ProcfsEntry::PidMounts(pid) + | ProcfsEntry::PidMountinfo(pid) + | ProcfsEntry::FdDir(pid) + | ProcfsEntry::FdInfoDir(pid) + | ProcfsEntry::Stat(pid) + | ProcfsEntry::Status(pid) + | ProcfsEntry::Cmdline(pid) + | ProcfsEntry::Environ(pid) + | ProcfsEntry::Maps(pid) + | ProcfsEntry::Cwd(pid) + | ProcfsEntry::Exe(pid) + | ProcfsEntry::Root_(pid) => Some(*pid), + ProcfsEntry::FdLink(pid, _) | ProcfsEntry::FdInfo(pid, _) => Some(*pid), + ProcfsEntry::NetDir(pid) | ProcfsEntry::NetTcp(pid) | ProcfsEntry::NetUnix(pid) => *pid, + ProcfsEntry::Root + | ProcfsEntry::Mounts + | ProcfsEntry::SelfLink + | ProcfsEntry::ThreadSelfLink => None, + } } // ── Path matching ─────────────────────────────────────────────────────────── @@ -181,7 +204,7 @@ pub fn match_procfs(path: &[u8], current_pid: u32) -> Option { (current_pid, &after[1..]) } } else if rest == b"net" || rest.starts_with(b"net/") { - return match_net_path(rest); + return match_net_path(rest, None); } else { match_pid_path(rest) }; @@ -245,7 +268,7 @@ fn match_pid_subpath(pid: u32, remainder: &[u8]) -> Option { let fd_str = &rem[7..]; parse_i32(fd_str).map(|fd| ProcfsEntry::FdInfo(pid, fd)) } else if rem == b"net" || rem.starts_with(b"net/") { - match_net_path(rem) + match_net_path(rem, Some(pid)) } else { None } @@ -254,14 +277,14 @@ fn match_pid_subpath(pid: u32, remainder: &[u8]) -> Option { } /// Match /proc/net/* paths. -fn match_net_path(rest: &[u8]) -> Option { +fn match_net_path(rest: &[u8], pid: Option) -> Option { if rest == b"net" || rest == b"net/" { - return Some(ProcfsEntry::NetDir); + return Some(ProcfsEntry::NetDir(pid)); } if rest.starts_with(b"net/") { match &rest[4..] { - b"tcp" => return Some(ProcfsEntry::NetTcp), - b"unix" => return Some(ProcfsEntry::NetUnix), + b"tcp" => return Some(ProcfsEntry::NetTcp(pid)), + b"unix" => return Some(ProcfsEntry::NetUnix(pid)), _ => return None, } } @@ -446,7 +469,6 @@ pub fn procfs_stat(entry: &ProcfsEntry, content_size: u64, follow_symlinks: bool st_ctime_sec: 0, st_ctime_nsec: 0, _pad: 0, - ..WasmStat::default() }; } @@ -467,7 +489,6 @@ pub fn procfs_stat(entry: &ProcfsEntry, content_size: u64, follow_symlinks: bool st_ctime_sec: 0, st_ctime_nsec: 0, _pad: 0, - ..WasmStat::default() }; } @@ -488,7 +509,6 @@ pub fn procfs_stat(entry: &ProcfsEntry, content_size: u64, follow_symlinks: bool st_ctime_sec: 0, st_ctime_nsec: 0, _pad: 0, - ..WasmStat::default() } } @@ -514,9 +534,9 @@ fn entry_ids(entry: &ProcfsEntry) -> (u32, u8) { ProcfsEntry::Cwd(pid) => (*pid, 16), ProcfsEntry::Exe(pid) => (*pid, 17), ProcfsEntry::Root_(pid) => (*pid, 18), - ProcfsEntry::NetDir => (0, 19), - ProcfsEntry::NetTcp => (0, 20), - ProcfsEntry::NetUnix => (0, 21), + ProcfsEntry::NetDir(pid) => (pid.unwrap_or(0), 19), + ProcfsEntry::NetTcp(pid) => (pid.unwrap_or(0), 20), + ProcfsEntry::NetUnix(pid) => (pid.unwrap_or(0), 21), } } @@ -568,8 +588,7 @@ pub fn procfs_open( if entry.is_dir() { // Validate that the target pid exists for pid-scoped directories - let target_pid = entry_pid(entry); - if target_pid != 0 { + if let Some(target_pid) = entry_pid(entry) { validate_pid(proc, target_pid)?; } let ofd_idx = proc.ofd_table.create( @@ -640,10 +659,16 @@ fn generate_content(proc: &Process, entry: &ProcfsEntry) -> Result, Errn validate_pid(proc, *pid)?; Ok(MOUNTINFO_CONTENT.to_vec()) } - ProcfsEntry::NetTcp => { + ProcfsEntry::NetTcp(pid) => { + if let Some(pid) = pid { + validate_pid(proc, *pid)?; + } Ok(b" sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n".to_vec()) } - ProcfsEntry::NetUnix => { + ProcfsEntry::NetUnix(pid) => { + if let Some(pid) = pid { + validate_pid(proc, *pid)?; + } Ok(b"Num RefCount Protocol Flags Type St Inode Path\n".to_vec()) } _ => Err(Errno::ENOENT), @@ -675,8 +700,7 @@ fn validate_pid(proc: &Process, pid: u32) -> Result<(), Errno> { /// validation as `procfs_open`, otherwise probes such as /// `test -r /proc/123/stat` incorrectly succeed for already-reaped pids. pub fn validate_entry(proc: &Process, entry: &ProcfsEntry) -> Result<(), Errno> { - let pid = entry_pid(entry); - if pid != 0 { + if let Some(pid) = entry_pid(entry) { validate_pid(proc, pid)?; } Ok(()) @@ -908,9 +932,10 @@ fn dir_entries( } } } - ProcfsEntry::NetDir => { - entries.push((b"tcp".to_vec(), DT_REG, procfs_ino(0, 20))); - entries.push((b"unix".to_vec(), DT_REG, procfs_ino(0, 21))); + ProcfsEntry::NetDir(pid) => { + let ino_pid = pid.unwrap_or(0); + entries.push((b"tcp".to_vec(), DT_REG, procfs_ino(ino_pid, 20))); + entries.push((b"unix".to_vec(), DT_REG, procfs_ino(ino_pid, 21))); } _ => return Err(Errno::ENOTDIR), } @@ -1044,12 +1069,33 @@ mod tests { #[test] fn test_match_procfs_net() { assert_eq!(match_procfs(b"/proc/mounts", 1), Some(ProcfsEntry::Mounts)); - assert_eq!(match_procfs(b"/proc/net", 1), Some(ProcfsEntry::NetDir)); - assert_eq!(match_procfs(b"/proc/net/tcp", 1), Some(ProcfsEntry::NetTcp)); + assert_eq!( + match_procfs(b"/proc/net", 1), + Some(ProcfsEntry::NetDir(None)) + ); + assert_eq!( + match_procfs(b"/proc/net/tcp", 1), + Some(ProcfsEntry::NetTcp(None)) + ); assert_eq!( match_procfs(b"/proc/net/unix", 1), - Some(ProcfsEntry::NetUnix) + Some(ProcfsEntry::NetUnix(None)) ); + assert_eq!( + match_procfs(b"/proc/42/net", 1), + Some(ProcfsEntry::NetDir(Some(42))) + ); + assert_eq!( + match_procfs(b"/proc/42/net/tcp", 1), + Some(ProcfsEntry::NetTcp(Some(42))) + ); + } + + #[test] + fn test_entry_pid_distinguishes_global_entries_from_pid_zero() { + assert_eq!(entry_pid(&ProcfsEntry::NetDir(None)), None); + assert_eq!(entry_pid(&ProcfsEntry::PidDir(0)), Some(0)); + assert_eq!(entry_pid(&ProcfsEntry::NetDir(Some(0))), Some(0)); } #[test] diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index 1b760b78c..7e775344d 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -4073,6 +4073,7 @@ pub fn sys_stat(proc: &mut Process, host: &mut dyn HostIO, path: &[u8]) -> Resul }); } if let Some(entry) = crate::procfs::match_procfs(&resolved, proc.pid) { + crate::procfs::validate_entry(proc, &entry)?; return Ok(crate::procfs::procfs_stat(&entry, 0, true)); } if let Some(st) = crate::devfs::match_devfs_stat(&resolved, proc.euid, proc.egid) { @@ -4122,6 +4123,7 @@ pub fn sys_lstat( }); } if let Some(entry) = crate::procfs::match_procfs(&resolved, proc.pid) { + crate::procfs::validate_entry(proc, &entry)?; return Ok(crate::procfs::procfs_stat(&entry, 0, false)); } if let Some(st) = crate::devfs::match_devfs_stat(&resolved, proc.euid, proc.egid) { @@ -4243,6 +4245,7 @@ pub fn sys_readlink( // Procfs symlinks — /proc/self, /proc/self/fd/N, /proc/self/cwd, /proc/self/exe, etc. if let Some(entry) = crate::procfs::match_procfs(&resolved, proc.pid) { + crate::procfs::validate_entry(proc, &entry)?; if entry.is_symlink() { return crate::procfs::procfs_readlink(proc, &entry, buf); } @@ -4299,7 +4302,8 @@ pub fn sys_access( { return Ok(()); } - if crate::procfs::match_procfs(&resolved, proc.pid).is_some() { + if let Some(entry) = crate::procfs::match_procfs(&resolved, proc.pid) { + crate::procfs::validate_entry(proc, &entry)?; // Procfs entries are read-only: allow R_OK/F_OK/X_OK(dirs), deny W_OK if amode & 0o2 != 0 { return Err(Errno::EACCES); @@ -4317,6 +4321,7 @@ pub fn sys_chdir(proc: &mut Process, host: &mut dyn HostIO, path: &[u8]) -> Resu let resolved = crate::path::resolve_path(path, &proc.cwd); // Check virtual filesystems first (procfs, devfs), then fall through to host if let Some(entry) = crate::procfs::match_procfs(&resolved, proc.pid) { + crate::procfs::validate_entry(proc, &entry)?; let st = crate::procfs::procfs_stat(&entry, 0, true); if st.st_mode & wasm_posix_shared::mode::S_IFMT != wasm_posix_shared::mode::S_IFDIR { return Err(Errno::ENOTDIR); @@ -4658,20 +4663,26 @@ pub fn sys_getdents64( // Check if this is a cross-process directory (e.g. /proc//fd) #[cfg(any(target_arch = "wasm32", target_arch = "wasm64"))] if let Some(entry) = crate::procfs::match_procfs(&path, proc.pid) { - let target_pid = crate::procfs::entry_pid(&entry); - if target_pid != 0 && target_pid != proc.pid { - if let Some((bytes, new_offset, exhausted)) = - crate::wasm_api::procfs_getdents64_for_pid(target_pid, &path, buf, entry_offset) - { - if let Some(ofd) = proc.ofd_table.get_mut(ofd_idx) { - ofd.dir_entry_offset = new_offset; - if exhausted { - ofd.dir_host_handle = -2; + if let Some(target_pid) = crate::procfs::entry_pid(&entry) { + if target_pid != proc.pid { + if let Some((bytes, new_offset, exhausted)) = + crate::wasm_api::procfs_getdents64_for_pid( + target_pid, + &path, + buf, + entry_offset, + ) + { + if let Some(ofd) = proc.ofd_table.get_mut(ofd_idx) { + ofd.dir_entry_offset = new_offset; + if exhausted { + ofd.dir_host_handle = -2; + } } + return Ok(bytes); } - return Ok(bytes); + return Err(Errno::ENOENT); } - return Err(Errno::ENOENT); } } @@ -8277,6 +8288,7 @@ pub fn sys_fstatat( }); } if let Some(entry) = crate::procfs::match_procfs(&resolved, proc.pid) { + crate::procfs::validate_entry(proc, &entry)?; let follow = flags & AT_SYMLINK_NOFOLLOW == 0; return Ok(crate::procfs::procfs_stat(&entry, 0, follow)); } @@ -10188,7 +10200,8 @@ pub fn sys_faccessat( { return Ok(()); } - if crate::procfs::match_procfs(&resolved, proc.pid).is_some() { + if let Some(entry) = crate::procfs::match_procfs(&resolved, proc.pid) { + crate::procfs::validate_entry(proc, &entry)?; if amode & 0o2 != 0 { return Err(Errno::EACCES); } @@ -10290,6 +10303,7 @@ pub fn sys_readlinkat( // Procfs symlinks — /proc/self, /proc/self/fd/N, /proc/self/cwd, etc. if let Some(entry) = crate::procfs::match_procfs(&resolved, proc.pid) { + crate::procfs::validate_entry(proc, &entry)?; if entry.is_symlink() { return crate::procfs::procfs_readlink(proc, &entry, buf); } @@ -20212,6 +20226,74 @@ mod tests { ); } + #[test] + fn test_procfs_metadata_rejects_missing_pid_scopes() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let missing_paths: &[&[u8]] = &[ + b"/proc/0", + b"/proc/999", + b"/proc/999/stat", + b"/proc/999/net", + b"/proc/999/net/tcp", + ]; + + for path in missing_paths { + assert_eq!( + sys_stat(&mut proc, &mut host, path).unwrap_err(), + Errno::ENOENT + ); + assert_eq!( + sys_lstat(&mut proc, &mut host, path).unwrap_err(), + Errno::ENOENT + ); + assert_eq!( + sys_access(&mut proc, &mut host, path, 0), + Err(Errno::ENOENT) + ); + assert_eq!( + sys_fstatat(&mut proc, &mut host, AT_FDCWD, path, 0).unwrap_err(), + Errno::ENOENT + ); + assert_eq!( + sys_faccessat(&mut proc, &mut host, AT_FDCWD, path, 0, 0), + Err(Errno::ENOENT) + ); + let mut link_buf = [0u8; 64]; + assert_eq!( + sys_readlink(&mut proc, &mut host, path, &mut link_buf).unwrap_err(), + Errno::ENOENT + ); + assert_eq!( + sys_readlinkat(&mut proc, &mut host, AT_FDCWD, path, &mut link_buf).unwrap_err(), + Errno::ENOENT + ); + } + + assert_eq!( + sys_chdir(&mut proc, &mut host, b"/proc/999"), + Err(Errno::ENOENT) + ); + assert_eq!( + sys_chdir(&mut proc, &mut host, b"/proc/999/net"), + Err(Errno::ENOENT) + ); + } + + #[test] + fn test_procfs_metadata_accepts_global_and_live_pid_net_paths() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + + for path in [b"/proc/net".as_slice(), b"/proc/1/net".as_slice()] { + let st = sys_stat(&mut proc, &mut host, path).unwrap(); + assert_eq!(st.st_mode & S_IFMT, S_IFDIR); + } + + let st = sys_stat(&mut proc, &mut host, b"/proc/1/net/tcp").unwrap(); + assert_eq!(st.st_mode & S_IFMT, S_IFREG); + } + #[test] fn test_procfs_open_write_fails() { let mut proc = Process::new(1); diff --git a/crates/kernel/src/wasm_api.rs b/crates/kernel/src/wasm_api.rs index 818bfe6d4..01a6a5a37 100644 --- a/crates/kernel/src/wasm_api.rs +++ b/crates/kernel/src/wasm_api.rs @@ -1058,7 +1058,7 @@ use crate::process_table::GLOBAL_PROCESS_TABLE as PROCESS_TABLE; /// Get all active PIDs from the process table. pub(crate) fn procfs_all_pids() -> Vec { let table = unsafe { &*PROCESS_TABLE.0.get() }; - table.all_pids() + table.procfs_pids() } /// Generate procfs content for a foreign process (cross-process access). @@ -1101,7 +1101,7 @@ pub(crate) fn procfs_getdents64_for_pid( ) -> Option<(usize, i64, bool)> { let table = unsafe { &*PROCESS_TABLE.0.get() }; let proc = table.get(pid)?; - let pids = table.all_pids(); + let pids = table.procfs_pids(); crate::procfs::procfs_getdents64(proc, ofd_path, buf, offset, &pids).ok() } diff --git a/docs/posix-status.md b/docs/posix-status.md index b42535f96..c95f37412 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -109,6 +109,7 @@ Kandelo uses a single kernel Wasm instance that holds a `ProcessTable` and serve | `getgid()` / `getegid()` | Full | Simulated; defaults to gid=0 (root). Configurable via setgid/setegid. | | `setuid()` / `seteuid()` | Full | POSIX semantics (no saved-set-uid tracked). As root: setuid sets both uid and euid; seteuid sets any euid. Non-root: setuid only to own uid; seteuid only to own ruid. Returns EPERM otherwise. | | `setgid()` / `setegid()` | Full | POSIX semantics mirroring setuid — gated on euid==0 for privileged changes. Returns EPERM for non-root trying to change to a foreign gid. | +| `getpriority()` / `setpriority()` | Partial | Stores a per-process nice value; WebAssembly has no host CPU scheduler to apply it to. Linux-compatible `/proc//stat` exposes scheduler priority in field 18 and nice in field 19. Procfs metadata operations reject missing or reaped PID scopes. | | `getpgrp()` | Full | Returns process group ID (simulated, defaults to pid). | | `setpgid()` | Partial | Sets process group ID. pid=0 means self. pgid=0 means use target pid. Only supports setting own pgid; other processes return ESRCH. | | `getsid()` | Full | Returns session ID (simulated, defaults to pid). pid=0 means self. | diff --git a/packages/registry/posix-utils-lite/build-posix-utils-lite.sh b/packages/registry/posix-utils-lite/build-posix-utils-lite.sh index 4c616a45d..115637b0f 100755 --- a/packages/registry/posix-utils-lite/build-posix-utils-lite.sh +++ b/packages/registry/posix-utils-lite/build-posix-utils-lite.sh @@ -10,6 +10,9 @@ SRC="$SCRIPT_DIR/src/posix-utils-lite.c" BIN_DIR="$SCRIPT_DIR/bin" SYSROOT="$REPO_ROOT/sysroot" +# Keep direct and resolver-driven builds pinned to this worktree's SDK. +source "$REPO_ROOT/sdk/activate.sh" + UTILITIES=( ar asa cal cflow compress ctags cxref ed ex fuser gencat getconf gettext iconv ipcrm ipcs lex locale logger man more msgfmt ngettext nm patch pax diff --git a/packages/registry/posix-utils-lite/build.toml b/packages/registry/posix-utils-lite/build.toml index 22d203eea..49e50603f 100644 --- a/packages/registry/posix-utils-lite/build.toml +++ b/packages/registry/posix-utils-lite/build.toml @@ -1,7 +1,7 @@ script_path = "packages/registry/posix-utils-lite/build-posix-utils-lite.sh" repo_url = "https://github.com/brandonpayton/kandelo.git" commit = "13930e1d2352048372847b6ec6cede8ebb25b5f8" -revision = 2 +revision = 3 [binary] index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/posix-utils-lite/src/posix-utils-lite.c b/packages/registry/posix-utils-lite/src/posix-utils-lite.c index 63d210b43..2a3a598fd 100644 --- a/packages/registry/posix-utils-lite/src/posix-utils-lite.c +++ b/packages/registry/posix-utils-lite/src/posix-utils-lite.c @@ -433,16 +433,29 @@ static int ps_pid_selected(long pid, long *pids, int npids) { return 0; } -static void ps_add_pid_list(const char *arg, long *pids, int *npids, int max_pids) { +static int ps_add_pid_list(const char *arg, long *pids, int *npids, int max_pids) { + if (!arg || !*arg) { + return -1; + } const char *p = arg; - while (*p && *npids < max_pids) { + for (;;) { + if (*npids >= max_pids) { + return -1; + } + errno = 0; char *end = NULL; long pid = strtol(p, &end, 10); - if (end == p) { - break; + if (errno != 0 || end == p || pid <= 0 || (*end != '\0' && *end != ',')) { + return -1; } pids[(*npids)++] = pid; - p = (*end == ',') ? end + 1 : end; + if (*end == '\0') { + return 0; + } + p = end + 1; + if (*p == '\0') { + return -1; + } } } @@ -502,10 +515,16 @@ static int ps_read_stat(long pid, char *comm, size_t comm_len, long *nice_value) char *save = NULL; int index = 0; for (char *tok = strtok_r(suffix, " \t\r\n", &save); tok; - tok = strtok_r(NULL, " \t\r\n", &save), index++) { + tok = strtok_r(NULL, " \t\r\n", &save), index++) { if (index == 16) { + char *end = NULL; + errno = 0; + long parsed = strtol(tok, &end, 10); + if (errno != 0 || end == tok || *end != '\0') { + return -1; + } if (nice_value) { - *nice_value = strtol(tok, NULL, 10); + *nice_value = parsed; } return 0; } @@ -564,25 +583,40 @@ static int util_ps(int argc, char **argv) { struct ps_fields fields = {1, 0, 1}; for (int i = 1; i < argc; i++) { - if ((streq(argv[i], "-p") || streq(argv[i], "--pid")) && i + 1 < argc) { - ps_add_pid_list(argv[++i], selected_pids, &nselected, - (int)(sizeof(selected_pids) / sizeof(selected_pids[0]))); + if (streq(argv[i], "-p") || streq(argv[i], "--pid")) { + if (i + 1 >= argc || + ps_add_pid_list(argv[++i], selected_pids, &nselected, + (int)(sizeof(selected_pids) / sizeof(selected_pids[0]))) != 0) { + fprintf(stderr, "ps: invalid pid list\n"); + return 2; + } } else if (strncmp(argv[i], "-p", 2) == 0 && argv[i][2]) { - ps_add_pid_list(argv[i] + 2, selected_pids, &nselected, - (int)(sizeof(selected_pids) / sizeof(selected_pids[0]))); - } else if ((streq(argv[i], "-o") || streq(argv[i], "--format")) && i + 1 < argc) { + if (ps_add_pid_list(argv[i] + 2, selected_pids, &nselected, + (int)(sizeof(selected_pids) / sizeof(selected_pids[0]))) != 0) { + fprintf(stderr, "ps: invalid pid list\n"); + return 2; + } + } else if (streq(argv[i], "-o") || streq(argv[i], "--format")) { + if (i + 1 >= argc) { + fprintf(stderr, "ps: option requires a format\n"); + return 2; + } ps_parse_fields(argv[++i], &fields); } else if (strncmp(argv[i], "-o", 2) == 0 && argv[i][2]) { ps_parse_fields(argv[i] + 2, &fields); + } else if (!streq(argv[i], "-A") && !streq(argv[i], "-e")) { + fprintf(stderr, "ps: unsupported option: %s\n", argv[i]); + return 2; } } DIR *proc = opendir("/proc"); - ps_print_header(&fields); if (!proc) { - ps_print_row(&fields, (long)getpid(), 0, program_name(argv[0])); - return 0; + perror("ps: /proc"); + return 1; } + ps_print_header(&fields); + int matched = 0; struct dirent *de; while ((de = readdir(proc)) != NULL) { char *end = NULL; @@ -596,7 +630,11 @@ static int util_ps(int argc, char **argv) { char cmd[256] = ""; char comm[256] = ""; long nice_value = 0; - ps_read_stat(pid, comm, sizeof(comm), &nice_value); + if (ps_read_stat(pid, comm, sizeof(comm), &nice_value) != 0) { + // The process may have exited between readdir() and fopen(). Do + // not fabricate a row or a nice value for a vanished process. + continue; + } if (cmd[0] == '\0') { ps_read_cmdline(pid, cmd, sizeof(cmd)); } @@ -604,9 +642,10 @@ static int util_ps(int argc, char **argv) { snprintf(cmd, sizeof(cmd), "%s", comm); } ps_print_row(&fields, pid, nice_value, cmd); + matched++; } closedir(proc); - return 0; + return nselected > 0 && matched == 0 ? 1 : 0; } static int util_renice(int argc, char **argv) {