diff --git a/apps/browser-demos/test/dev-fd-stat.spec.ts b/apps/browser-demos/test/dev-fd-stat.spec.ts new file mode 100644 index 0000000000..78017f25a0 --- /dev/null +++ b/apps/browser-demos/test/dev-fd-stat.spec.ts @@ -0,0 +1,22 @@ +import { expect, test } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import { tryResolveBinary } from "../../../host/src/binary-resolver"; + +const devFdStatBinary = tryResolveBinary("programs/dev-fd-stat.wasm"); + +test.skip(!devFdStatBinary, "dev-fd-stat.wasm was not built"); + +test("devfs descriptor aliases preserve stat identity in BrowserKernel", async ({ page }) => { + await page.goto("/pages/test-runner/"); + await page.waitForFunction(() => (window as any).__testRunnerReady === true); + + const bytes = Array.from(readFileSync(devFdStatBinary!)); + const result = await page.evaluate(async (wasmBytes) => { + const wasm = new Uint8Array(wasmBytes).buffer; + return (window as any).__runTest(wasm, ["dev-fd-stat"]); + }, bytes); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toBe("PASS\n"); + expect(result.stderr).toBe(""); +}); diff --git a/crates/kernel/src/devfs.rs b/crates/kernel/src/devfs.rs index d0be372a05..8549e6cbe9 100644 --- a/crates/kernel/src/devfs.rs +++ b/crates/kernel/src/devfs.rs @@ -8,7 +8,7 @@ extern crate alloc; use alloc::vec::Vec; -use wasm_posix_shared::mode::S_IFDIR; +use wasm_posix_shared::mode::{S_IFDIR, S_IFLNK}; use wasm_posix_shared::{Errno, WasmStat}; /// Sentinel host_handle for devfs directory OFDs. @@ -55,7 +55,7 @@ pub fn match_devfs_dir(path: &[u8]) -> Option { pub fn match_devfs_stat(path: &[u8], uid: u32, gid: u32) -> Option { if let Some(_entry) = match_devfs_dir(path) { return Some(WasmStat { - st_dev: 6, + st_dev: 5, st_ino: devfs_ino(path), st_mode: S_IFDIR | 0o755, st_nlink: 2, @@ -74,6 +74,29 @@ pub fn match_devfs_stat(path: &[u8], uid: u32, gid: u32) -> Option { None } +/// Build metadata for a synthetic symlink owned by devfs. +/// +/// The inode comes from the same path-based namespace used by directory +/// entries, so repeated lstat calls and getdents report the same identity. +pub fn devfs_symlink_stat(path: &[u8], target_len: usize, uid: u32, gid: u32) -> WasmStat { + WasmStat { + st_dev: 5, + st_ino: devfs_ino(path), + st_mode: S_IFLNK | 0o777, + st_nlink: 1, + st_uid: uid, + st_gid: gid, + st_size: target_len as u64, + 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, + } +} + /// Open a devfs directory, creating an OFD with the sentinel handle. /// Returns the new fd number. pub fn devfs_open_dir( @@ -210,7 +233,8 @@ fn dir_entries(proc: &crate::process::Process, entry: &DevfsEntry) -> Vec<(Vec Option { None } +/// Return the target text exposed by readlink for a devfs descriptor alias. +fn dev_fd_link_target<'a>( + proc: &'a Process, + path: &[u8], + target_fd: i32, +) -> Result<&'a [u8], Errno> { + match path { + b"/dev/stdin" => Ok(b"/dev/fd/0"), + b"/dev/stdout" => Ok(b"/dev/fd/1"), + b"/dev/stderr" => Ok(b"/dev/fd/2"), + _ => { + let entry = proc.fd_table.get(target_fd).map_err(|_| Errno::ENOENT)?; + let ofd = proc + .ofd_table + .get(entry.ofd_ref.0) + .ok_or(Errno::ENOENT)?; + Ok(&ofd.path) + } + } +} + +fn dev_fd_lstat(proc: &Process, path: &[u8], target_fd: i32) -> Result { + let target = dev_fd_link_target(proc, path, target_fd)?; + Ok(crate::devfs::devfs_symlink_stat( + path, + target.len(), + proc.euid, + proc.egid, + )) +} + +fn stat_dev_fd( + proc: &mut Process, + host: &mut dyn HostIO, + target_fd: i32, +) -> Result { + sys_fstat(proc, host, target_fd).map_err(|err| { + if err == Errno::EBADF { + Errno::ENOENT + } else { + err + } + }) +} + /// Try to claim `/dev/fb0` for the calling process. /// /// `/dev/fb0` is single-owner: at most one process at a time can have an @@ -1806,6 +1851,9 @@ pub fn sys_open( // /dev/fd/N and /dev/stdin|stdout|stderr — dup an existing fd if let Some(target_fd) = match_dev_fd(&resolved) { + if oflags & O_NOFOLLOW != 0 { + return Err(Errno::ELOOP); + } let entry = proc.fd_table.get(target_fd)?; let ofd_ref = entry.ofd_ref; proc.ofd_table.inc_ref(ofd_ref.0); @@ -4018,24 +4066,8 @@ pub fn sys_stat(proc: &mut Process, host: &mut dyn HostIO, path: &[u8]) -> Resul if let Some(st) = match_pty_stat(&resolved, proc.euid, proc.egid) { return Ok(st); } - if match_dev_fd(&resolved).is_some() { - use wasm_posix_shared::mode::S_IFCHR; - return Ok(WasmStat { - st_dev: 5, - st_ino: 0, - st_mode: S_IFCHR | 0o666, - 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(target_fd) = match_dev_fd(&resolved) { + return stat_dev_fd(proc, host, target_fd); } if let Some(entry) = crate::procfs::match_procfs(&resolved, proc.pid) { return Ok(crate::procfs::procfs_stat(&entry, 0, true)); @@ -4086,24 +4118,8 @@ pub fn sys_lstat( if let Some(st) = match_pty_stat(&resolved, proc.euid, proc.egid) { return Ok(st); } - if match_dev_fd(&resolved).is_some() { - use wasm_posix_shared::mode::S_IFCHR; - return Ok(WasmStat { - st_dev: 5, - st_ino: 0, - st_mode: S_IFCHR | 0o666, - 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(target_fd) = match_dev_fd(&resolved) { + return dev_fd_lstat(proc, &resolved, target_fd); } if let Some(entry) = crate::procfs::match_procfs(&resolved, proc.pid) { return Ok(crate::procfs::procfs_stat(&entry, 0, false)); @@ -4253,6 +4269,13 @@ pub fn sys_readlink( return Err(Errno::EINVAL); } + if let Some(target_fd) = match_dev_fd(&resolved) { + let target = dev_fd_link_target(proc, &resolved, target_fd)?; + let n = buf.len().min(target.len()); + buf[..n].copy_from_slice(&target[..n]); + return Ok(n); + } + check_search_path(proc, host, &resolved)?; host.host_readlink(&resolved, buf) } @@ -8054,6 +8077,9 @@ pub fn sys_openat( // /dev/fd/N and /dev/stdin|stdout|stderr — dup an existing fd if let Some(target_fd) = match_dev_fd(&resolved) { + if oflags & O_NOFOLLOW != 0 { + return Err(Errno::ELOOP); + } let entry = proc.fd_table.get(target_fd)?; let ofd_ref = entry.ofd_ref; proc.ofd_table.inc_ref(ofd_ref.0); @@ -8226,24 +8252,11 @@ pub fn sys_fstatat( if let Some(dev) = match_virtual_device(&resolved) { return Ok(virtual_device_stat(dev, proc.euid, proc.egid)); } - if match_dev_fd(&resolved).is_some() { - use wasm_posix_shared::mode::S_IFCHR; - return Ok(WasmStat { - st_dev: 5, - st_ino: 0, - st_mode: S_IFCHR | 0o666, - 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(target_fd) = match_dev_fd(&resolved) { + if flags & AT_SYMLINK_NOFOLLOW != 0 { + return dev_fd_lstat(proc, &resolved, target_fd); + } + return stat_dev_fd(proc, host, target_fd); } if let Some(entry) = crate::procfs::match_procfs(&resolved, proc.pid) { let follow = flags & AT_SYMLINK_NOFOLLOW == 0; @@ -10282,6 +10295,13 @@ pub fn sys_readlinkat( return Err(Errno::EINVAL); } + if let Some(target_fd) = match_dev_fd(&resolved) { + let target = dev_fd_link_target(proc, &resolved, target_fd)?; + let n = buf.len().min(target.len()); + buf[..n].copy_from_slice(&target[..n]); + return Ok(n); + } + check_search_path(proc, host, &resolved)?; host.host_readlink(&resolved, buf) } @@ -10735,7 +10755,7 @@ fn virtual_statfs_for_path(resolved: &[u8], pid: u32) -> Option { || resolved == b"/dev/ptmx" || resolved == b"/dev/tty" || resolved.starts_with(b"/dev/pts/") - || resolved.starts_with(b"/dev/fd/") + || match_dev_fd(resolved).is_some() { return Some(devfs_statfs()); } @@ -17276,11 +17296,62 @@ mod tests { } #[test] - fn test_stat_dev_fd_path() { + fn test_dev_fd_stat_follows_descriptor_but_lstat_reports_symlink() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let st = sys_stat(&mut proc, &mut host, b"/dev/fd/0").unwrap(); - assert_eq!(st.st_mode & 0xF000, wasm_posix_shared::mode::S_IFCHR); + let fd_stat = sys_fstat(&mut proc, &mut host, 0).unwrap(); + + for path in [b"/dev/stdin".as_slice(), b"/dev/fd/0".as_slice()] { + let path_stat = sys_stat(&mut proc, &mut host, path).unwrap(); + assert_eq!(path_stat.st_dev, fd_stat.st_dev); + assert_eq!(path_stat.st_ino, fd_stat.st_ino); + assert_eq!(path_stat.st_mode, fd_stat.st_mode); + + let at_stat = sys_fstatat(&mut proc, &mut host, AT_FDCWD, path, 0).unwrap(); + assert_eq!(at_stat.st_dev, fd_stat.st_dev); + assert_eq!(at_stat.st_ino, fd_stat.st_ino); + assert_eq!(at_stat.st_mode, fd_stat.st_mode); + + let link_stat = sys_lstat(&mut proc, &mut host, path).unwrap(); + assert_eq!(link_stat.st_mode & S_IFMT, S_IFLNK); + let nofollow_stat = sys_fstatat( + &mut proc, + &mut host, + AT_FDCWD, + path, + AT_SYMLINK_NOFOLLOW, + ) + .unwrap(); + assert_eq!(nofollow_stat.st_ino, link_stat.st_ino); + assert_eq!(nofollow_stat.st_mode, link_stat.st_mode); + } + } + + #[test] + fn test_dev_fd_alias_follows_arbitrary_open_descriptor() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_open(&mut proc, &mut host, b"/dev/null", O_RDONLY, 0).unwrap(); + let path = alloc::format!("/dev/fd/{fd}").into_bytes(); + + let fd_stat = sys_fstat(&mut proc, &mut host, fd).unwrap(); + let path_stat = sys_stat(&mut proc, &mut host, &path).unwrap(); + assert_eq!(path_stat.st_dev, fd_stat.st_dev); + assert_eq!(path_stat.st_ino, fd_stat.st_ino); + assert_eq!(path_stat.st_mode, fd_stat.st_mode); + + let mut buf = [0u8; 32]; + let n = sys_readlink(&mut proc, &mut host, &path, &mut buf).unwrap(); + assert_eq!(&buf[..n], b"/dev/null"); + } + + #[test] + fn test_dev_stdio_readlink_targets_dev_fd() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let mut buf = [0u8; 32]; + let n = sys_readlink(&mut proc, &mut host, b"/dev/stdin", &mut buf).unwrap(); + assert_eq!(&buf[..n], b"/dev/fd/0"); } #[test] @@ -17441,6 +17512,33 @@ mod tests { assert_eq!(ofd_ref_0, ofd_ref_new); } + #[test] + fn test_open_dev_fd_nofollow_rejects_symlink() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + assert_eq!( + sys_open( + &mut proc, + &mut host, + b"/dev/stdin", + O_RDONLY | O_NOFOLLOW, + 0, + ), + Err(Errno::ELOOP), + ); + assert_eq!( + sys_openat( + &mut proc, + &mut host, + AT_FDCWD, + b"/dev/fd/0", + O_RDONLY | O_NOFOLLOW, + 0, + ), + Err(Errno::ELOOP), + ); + } + #[test] fn test_open_dev_fd_nonexistent() { let mut proc = Process::new(1); diff --git a/docs/posix-status.md b/docs/posix-status.md index dbec136eef..2d9ae43ab4 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -335,10 +335,10 @@ shortcuts. | `/dev/zero` | Full | Read fills buffer with zeros. Write discards data (returns count). | | `/dev/urandom` / `/dev/random` | Full | Read delegates to `host_getrandom()` (crypto.getRandomValues on host). Write discards. | | `/dev/full` | Full | Read fills buffer with zeros. Write returns ENOSPC. | -| `/dev/fd/N` | Full | Open-time dup of fd N. Validates target fd exists (EBADF if not). | -| `/dev/stdin` | Full | Alias for `/dev/fd/0`. | -| `/dev/stdout` | Full | Alias for `/dev/fd/1`. | -| `/dev/stderr` | Full | Alias for `/dev/fd/2`. | +| `/dev/fd/N` | Full | Symlink-like descriptor alias. `open()` duplicates fd N; following `stat()`/`fstatat()` returns the same metadata as `fstat(N)`, while `lstat()`/`AT_SYMLINK_NOFOLLOW` reports the devfs symlink. `readlink()` returns the open file description's path. Opening validates the target fd exists (EBADF if not). | +| `/dev/stdin` | Full | Symlink alias for `/dev/fd/0`; following metadata is fd 0 metadata. | +| `/dev/stdout` | Full | Symlink alias for `/dev/fd/1`; following metadata is fd 1 metadata. | +| `/dev/stderr` | Full | Symlink alias for `/dev/fd/2`; following metadata is fd 2 metadata. | | `/dev/tty` | Full | Controlling terminal. Opens the session's controlling PTY slave (ENXIO if none). | | `/dev/ptmx` | Full | PTY master multiplexer. `open()` allocates a new PTY pair, returns master fd. | | `/dev/pts/*` | Full | PTY slave devices. `posix_openpt()` + `grantpt()` + `unlockpt()` + `ptsname()`. Full line discipline, canonical/raw mode, OPOST/ONLCR, 16 terminal ioctls. | @@ -347,7 +347,7 @@ shortcuts. | `/dev/dsp` | Full (write-only) | OSS-style PCM audio sink. Single-open (`EBUSY` for second pid). `write()` accepts interleaved 16-bit-LE PCM and buffers it in a 256 KiB ring; the host drains via the `kernel_drain_audio` wasm export and feeds a Web Audio `AudioContext`. ioctls: `SNDCTL_DSP_RESET`, `SNDCTL_DSP_SYNC`, `SNDCTL_DSP_SPEED` (clamp 4000–192000 Hz), `SNDCTL_DSP_STEREO` / `SNDCTL_DSP_CHANNELS` (1 or 2), `SNDCTL_DSP_SETFMT` (only `AFMT_S16_LE`), `SNDCTL_DSP_GETFMTS`, `SNDCTL_DSP_SETFRAGMENT` (accept-and-acknowledge). On overflow drops the *oldest whole frame* — never tears L/R alignment. Ownership released on close-of-last-fd / `execve` / `exit`; the ring is flushed at the same time. `read()` returns 0 (EOF-like). `poll()` reports `POLLOUT` always, never `POLLIN`. No record path, no `mmap`-based zero-copy; DOOM's mixer is in user space. | | `/dev/shm/*` | Not yet | POSIX shared memory — requires cross-process SharedArrayBuffer. | -All virtual devices return synthetic `stat()` with `S_IFCHR | 0666`, deterministic inode numbers, and `st_dev=5`. Path interception in kernel before host delegation — no host filesystem changes needed. `access()` returns OK for all virtual devices. +Character-device entries return synthetic `stat()` with deterministic inode numbers and `st_dev=5`. Descriptor aliases are devfs symlinks: following metadata comes from the referenced descriptor, and no-follow metadata uses a deterministic devfs inode. Path interception happens in the kernel before host delegation, so Node.js and browser hosts share the same behavior without host filesystem changes. `access()` returns OK for all virtual devices. ## Environment diff --git a/host/test/dev-fd-stat.test.ts b/host/test/dev-fd-stat.test.ts new file mode 100644 index 0000000000..96ce081234 --- /dev/null +++ b/host/test/dev-fd-stat.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { tryResolveBinary } from "../src/binary-resolver"; +import { runCentralizedProgram } from "./centralized-test-helper"; + +const devFdStatBinary = tryResolveBinary("programs/dev-fd-stat.wasm"); + +describe("devfs descriptor aliases", () => { + it.skipIf(!devFdStatBinary)( + "stat follows /dev/std{in,out,err} and /dev/fd/N while lstat reports symlinks", + async () => { + const result = await runCentralizedProgram({ + programPath: devFdStatBinary!, + argv: ["dev-fd-stat"], + useDefaultRootfs: false, + }); + + expect(result.exitCode, `stderr=${result.stderr}`).toBe(0); + expect(result.stdout).toBe("PASS\n"); + expect(result.stderr).toBe(""); + }, + ); +}); diff --git a/programs/dev-fd-stat.c b/programs/dev-fd-stat.c new file mode 100644 index 0000000000..f7406f2cfa --- /dev/null +++ b/programs/dev-fd-stat.c @@ -0,0 +1,97 @@ +#include +#include +#include +#include + +static int same_file_identity(const struct stat *left, const struct stat *right) { + return left->st_dev == right->st_dev && + left->st_ino == right->st_ino && + left->st_mode == right->st_mode; +} + +static int check_fd_alias(const char *path, int fd) { + struct stat fd_stat; + struct stat path_stat; + struct stat at_stat; + struct stat link_stat; + struct stat nofollow_stat; + char link_target[128]; + + if (fstat(fd, &fd_stat) != 0) { + perror("fstat"); + return 1; + } + if (stat(path, &path_stat) != 0) { + perror(path); + return 1; + } + if (!same_file_identity(&path_stat, &fd_stat)) { + fprintf(stderr, + "%s stat mismatch: path=(%llu,%llu,%o) fd=(%llu,%llu,%o)\n", + path, + (unsigned long long)path_stat.st_dev, + (unsigned long long)path_stat.st_ino, + path_stat.st_mode, + (unsigned long long)fd_stat.st_dev, + (unsigned long long)fd_stat.st_ino, + fd_stat.st_mode); + return 1; + } + if (fstatat(AT_FDCWD, path, &at_stat, 0) != 0) { + perror("fstatat"); + return 1; + } + if (!same_file_identity(&at_stat, &fd_stat)) { + fprintf(stderr, "%s fstatat did not follow the descriptor alias\n", path); + return 1; + } + if (lstat(path, &link_stat) != 0) { + perror("lstat"); + return 1; + } + if (!S_ISLNK(link_stat.st_mode)) { + fprintf(stderr, "%s lstat mode is %o, expected a symlink\n", path, + link_stat.st_mode); + return 1; + } + if (fstatat(AT_FDCWD, path, &nofollow_stat, AT_SYMLINK_NOFOLLOW) != 0) { + perror("fstatat nofollow"); + return 1; + } + if (!S_ISLNK(nofollow_stat.st_mode) || + nofollow_stat.st_ino != link_stat.st_ino) { + fprintf(stderr, "%s fstatat nofollow did not report the symlink\n", path); + return 1; + } + ssize_t link_len = readlink(path, link_target, sizeof(link_target)); + if (link_len <= 0 || link_stat.st_size != link_len) { + fprintf(stderr, "%s readlink disagrees with lstat size\n", path); + return 1; + } + return 0; +} + +int main(void) { + if (check_fd_alias("/dev/stdin", STDIN_FILENO) != 0 || + check_fd_alias("/dev/stdout", STDOUT_FILENO) != 0 || + check_fd_alias("/dev/stderr", STDERR_FILENO) != 0) { + return 1; + } + + int null_fd = open("/dev/null", O_RDWR); + if (null_fd < 0) { + perror("open /dev/null"); + return 1; + } + + char fd_path[32]; + snprintf(fd_path, sizeof(fd_path), "/dev/fd/%d", null_fd); + int result = check_fd_alias(fd_path, null_fd); + close(null_fd); + if (result != 0) { + return result; + } + + puts("PASS"); + return 0; +}