diff --git a/abi/snapshot.json b/abi/snapshot.json index 76e9ef31c..6390774b5 100644 --- a/abi/snapshot.json +++ b/abi/snapshot.json @@ -569,7 +569,7 @@ { "kind": "func", "name": "kernel_fpathconf", - "signature": "(i32,i32) -> (i64)" + "signature": "(i32,i32,i32) -> (i32)" }, { "kind": "func", @@ -1054,7 +1054,7 @@ { "kind": "func", "name": "kernel_pathconf", - "signature": "(i32,i32,i32) -> (i64)" + "signature": "(i32,i32,i32,i32) -> (i32)" }, { "kind": "func", @@ -2988,6 +2988,32 @@ "size": 16 } }, + "pathconf_names": { + "ALLOC_SIZE_MIN": 18, + "ASYNC_IO": 10, + "CHOWN_RESTRICTED": 6, + "FALLOC": 21, + "FILESIZEBITS": 13, + "LINK_MAX": 0, + "MAX_CANON": 1, + "MAX_INPUT": 2, + "NAME_MAX": 3, + "NO_TRUNC": 7, + "PATH_MAX": 4, + "PIPE_BUF": 5, + "POSIX2_SYMLINKS": 20, + "PRIO_IO": 11, + "REC_INCR_XFER_SIZE": 14, + "REC_MAX_XFER_SIZE": 15, + "REC_MIN_XFER_SIZE": 16, + "REC_XFER_ALIGN": 17, + "SOCK_MAXBUF": 12, + "SYMLINK_MAX": 19, + "SYNC_IO": 9, + "TEXTDOMAIN_MAX": 22, + "TIMESTAMP_RESOLUTION": 23, + "VDISABLE": 8 + }, "process_expected_globals": [ "__channel_base", "__tls_base" @@ -3168,6 +3194,35 @@ } } ], + "112": [ + { + "argIndex": 0, + "direction": "in", + "size": { + "type": "cstring" + } + }, + { + "argIndex": 2, + "direction": "out", + "required": true, + "size": { + "size": 8, + "type": "fixed" + } + } + ], + "113": [ + { + "argIndex": 2, + "direction": "out", + "required": true, + "size": { + "size": 8, + "type": "fixed" + } + } + ], "114": [ { "argIndex": 1, diff --git a/apps/browser-demos/test/fixtures/opfs-pathconf-client-worker.ts b/apps/browser-demos/test/fixtures/opfs-pathconf-client-worker.ts new file mode 100644 index 000000000..655559e6a --- /dev/null +++ b/apps/browser-demos/test/fixtures/opfs-pathconf-client-worker.ts @@ -0,0 +1,73 @@ +import { PATHCONF_NAMES } from "../../../../host/src/generated/abi"; +import { OpfsFileSystem } from "../../../../host/src/vfs/opfs"; + +const O_RDWR = 0x0002; +const O_CREAT = 0x0040; +const O_TRUNC = 0x0200; + +function errorName(action: () => unknown): string | null { + try { + action(); + return null; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } +} + +self.onmessage = ( + event: MessageEvent<{ buffer: SharedArrayBuffer; path: string }>, +) => { + const { buffer, path } = event.data; + const fs = OpfsFileSystem.create(buffer); + let fd = -1; + + try { + fd = fs.open(path, O_CREAT | O_TRUNC | O_RDWR, 0o600); + const result = { + type: "result", + // Keep the synchronous access handle open while pathname queries run: + // Chromium permits FileSystemFileHandle.getFile() here, but rejects a + // second createSyncAccessHandle() for the same file. + nameMax: fs.pathconf(path, PATHCONF_NAMES.NAME_MAX), + pathMax: fs.fpathconf(fd, PATHCONF_NAMES.PATH_MAX), + asyncIo: fs.fpathconf(fd, PATHCONF_NAMES.ASYNC_IO), + symlinks: fs.pathconf(path, PATHCONF_NAMES.POSIX2_SYMLINKS), + timestampResolution: fs.pathconf( + path, + PATHCONF_NAMES.TIMESTAMP_RESOLUTION, + ), + }; + + const closedFd = fd; + fs.close(closedFd); + fd = -1; + const closedHandleError = errorName(() => + fs.fpathconf(closedFd, PATHCONF_NAMES.NAME_MAX), + ); + fs.unlink(path); + const missingPathError = errorName(() => + fs.pathconf(path, PATHCONF_NAMES.NAME_MAX), + ); + + self.postMessage({ ...result, closedHandleError, missingPathError }); + } catch (error) { + if (fd >= 0) { + try { + fs.close(fd); + } catch { + // Preserve the original failure. + } + } + try { + fs.unlink(path); + } catch { + // Preserve the original failure. + } + self.postMessage({ + type: "error", + error: error instanceof Error ? error.message : String(error), + }); + } finally { + self.close(); + } +}; diff --git a/apps/browser-demos/test/opfs-pathconf.spec.ts b/apps/browser-demos/test/opfs-pathconf.spec.ts new file mode 100644 index 000000000..02443d3f8 --- /dev/null +++ b/apps/browser-demos/test/opfs-pathconf.spec.ts @@ -0,0 +1,110 @@ +import { expect, test } from "@playwright/test"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const proxyWorkerPath = resolve( + __dirname, + "../../../host/src/vfs/opfs-worker.ts", +); +const clientWorkerPath = resolve( + __dirname, + "fixtures/opfs-pathconf-client-worker.ts", +); + +test("OPFS reports path configuration from live paths and handles", async ({ + page, + baseURL, + browserName, +}) => { + test.skip( + browserName !== "chromium", + "OPFS sync access handles are Chromium-only here", + ); + expect(baseURL).toBeTruthy(); + + const proxyWorkerUrl = new URL(`/@fs/${proxyWorkerPath}`, baseURL).href; + const clientWorkerUrl = new URL(`/@fs/${clientWorkerPath}`, baseURL).href; + await page.goto(new URL("/trap-signal-test.html", baseURL).href); + + const result = await page.evaluate( + async ({ proxyWorkerUrl, clientWorkerUrl }) => { + const buffer = new SharedArrayBuffer(4 * 1024 * 1024); + const proxy = new Worker(proxyWorkerUrl, { type: "module" }); + const client = new Worker(clientWorkerUrl, { type: "module" }); + + const receive = (worker: Worker, expectedType: string): Promise => + new Promise((resolvePromise, reject) => { + const timeout = setTimeout( + () => reject(new Error(`timed out waiting for ${expectedType}`)), + 15_000, + ); + worker.addEventListener( + "message", + (event) => { + if (event.data?.type !== expectedType) { + if (event.data?.type === "error") { + clearTimeout(timeout); + reject(new Error(event.data.error)); + } + return; + } + clearTimeout(timeout); + resolvePromise(event.data as T); + }, + { once: false }, + ); + worker.addEventListener( + "error", + (event) => { + clearTimeout(timeout); + reject( + new Error( + `${expectedType}: ${event.message || "worker module failed to load"} ` + + `(${event.filename}:${event.lineno}:${event.colno})`, + ), + ); + }, + { once: true }, + ); + }); + + try { + const ready = receive<{ type: "ready" }>(proxy, "ready"); + proxy.postMessage({ type: "init", buffer }); + await ready; + + const pending = receive<{ + type: "result"; + nameMax: number; + pathMax: number; + asyncIo: number; + symlinks: null; + timestampResolution: null; + closedHandleError: string; + missingPathError: string; + }>(client, "result"); + client.postMessage({ + buffer, + path: `/kandelo-opfs-pathconf-${crypto.randomUUID()}`, + }); + return await pending; + } finally { + client.terminate(); + proxy.terminate(); + } + }, + { proxyWorkerUrl, clientWorkerUrl }, + ); + + expect(result).toEqual({ + type: "result", + nameMax: 255, + pathMax: 4096, + asyncIo: 1, + symlinks: null, + timestampResolution: null, + closedHandleError: "EBADF", + missingPathError: "ENOENT", + }); +}); diff --git a/apps/browser-demos/test/pathconf.spec.ts b/apps/browser-demos/test/pathconf.spec.ts new file mode 100644 index 000000000..69fd6bd43 --- /dev/null +++ b/apps/browser-demos/test/pathconf.spec.ts @@ -0,0 +1,52 @@ +import { expect, test } from "@playwright/test"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const programPath = resolve(__dirname, "../../../examples/pathconf_test.wasm"); + +test("pathconf values, errno, and pointer validation work in Chromium", async ({ + page, + baseURL, + browserName, +}) => { + test.skip( + browserName !== "chromium", + "the aggregate browser gate uses Chromium", + ); + expect(baseURL).toBeTruthy(); + + const runtimeErrors: string[] = []; + page.on("console", (message) => { + if (message.type() === "error") { + runtimeErrors.push(`console: ${message.text()}`); + } + }); + page.on("pageerror", (error) => { + runtimeErrors.push(`pageerror: ${error.message}`); + }); + + await page.goto(new URL("/pages/test-runner/", baseURL).href); + await page.waitForFunction(() => (window as any).__testRunnerReady === true); + + const programUrl = new URL(`/@fs/${programPath}`, baseURL).href; + const result = await page.evaluate( + async ({ programUrl }) => { + const response = await fetch(programUrl); + if (!response.ok) { + throw new Error(`program fetch failed: ${response.status}`); + } + return (window as any).__runTest( + await response.arrayBuffer(), + ["pathconf-test"], + 15_000, + ); + }, + { programUrl }, + ); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("PATHCONF_PASS"); + expect(result.stderr).toBe(""); + expect(runtimeErrors).toEqual([]); +}); diff --git a/crates/kernel/src/process.rs b/crates/kernel/src/process.rs index 4642b7d69..4cab78053 100644 --- a/crates/kernel/src/process.rs +++ b/crates/kernel/src/process.rs @@ -34,6 +34,12 @@ pub trait HostIO { fn host_statfs(&mut self, _path: &[u8]) -> Result { Err(Errno::ENOSYS) } + fn host_pathconf(&mut self, _path: &[u8], _name: i32) -> Result, Errno> { + Err(Errno::ENOSYS) + } + fn host_fpathconf(&mut self, _handle: i64, _name: i32) -> Result, Errno> { + Err(Errno::ENOSYS) + } fn host_mkdir(&mut self, path: &[u8], mode: u32) -> Result<(), Errno>; fn host_rmdir(&mut self, path: &[u8]) -> Result<(), Errno>; fn host_unlink(&mut self, path: &[u8]) -> Result<(), Errno>; diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index b65a51188..965426e13 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -1706,6 +1706,14 @@ fn check_access_for_ids(uid: u32, gid: u32, st: &WasmStat, amode: u32) -> Result } } +/// `PATH_MAX` includes the terminating NUL; caller pathnames and bounded +/// symlink substitutions may therefore contain at most 4095 bytes. Internal +/// canonical paths may be longer when resolving a short relative pathname +/// from a deep CWD. Component limits are byte limits as required by the guest +/// ABI, not JavaScript UTF-16 code-unit limits in a host backend. +const NAMESPACE_PATH_MAX: usize = 4096; +const NAMESPACE_NAME_MAX: usize = 255; + #[derive(Clone, Copy)] struct PathResolveOptions { follow_final_symlink: bool, @@ -1836,7 +1844,7 @@ fn namespace_readlink_raw( host: &mut dyn HostIO, path: &[u8], ) -> Result, Errno> { - let mut target = alloc::vec![0u8; 4096]; + let mut target = alloc::vec![0u8; NAMESPACE_PATH_MAX]; let len = if let Some(entry) = crate::procfs::match_procfs(path, proc.pid) { crate::procfs::validate_entry(proc, &entry)?; if !entry.is_symlink() { @@ -1846,6 +1854,9 @@ fn namespace_readlink_raw( } else { host.host_readlink(path, &mut target)? }; + if len >= NAMESPACE_PATH_MAX { + return Err(Errno::ENAMETOOLONG); + } target.truncate(len); if target.is_empty() { return Err(Errno::ENOENT); @@ -1871,6 +1882,19 @@ fn pop_path_component(path: &mut Vec) { } } +fn substituted_path_len(target: &[u8], pending: &VecDeque>) -> Option { + let mut len = target.len(); + let mut needs_separator = target.last() != Some(&b'/'); + for component in pending { + if needs_separator { + len = len.checked_add(1)?; + } + len = len.checked_add(component.len())?; + needs_separator = true; + } + Some(len) +} + /// Resolve a pathname through Kandelo's global namespace one component at a /// time. Backends only receive canonical candidates, so `..` can cross mount /// roots and symlink targets can cross mounts without being trapped in the @@ -1885,6 +1909,9 @@ fn resolve_namespace_path_from( if path.is_empty() { return Err(Errno::ENOENT); } + if path.len() >= NAMESPACE_PATH_MAX { + return Err(Errno::ENAMETOOLONG); + } let absolute = crate::path::make_absolute(path, base); if absolute.first() != Some(&b'/') { @@ -1897,6 +1924,12 @@ fn resolve_namespace_path_from( .filter(|component| !component.is_empty()) .map(|component| component.to_vec()) .collect(); + if pending + .iter() + .any(|component| component.len() > NAMESPACE_NAME_MAX) + { + return Err(Errno::ENAMETOOLONG); + } if pending.back().map(Vec::as_slice) == Some(b".") || pending.back().map(Vec::as_slice) == Some(b"..") { @@ -1979,7 +2012,6 @@ fn resolve_namespace_path_from( final_stat = Some(namespace_lstat_raw(proc, host, &resolved)?); continue; } - let target = namespace_readlink_raw(proc, host, &candidate)?; // `/proc//fd/N` is a Linux-style magic link whose OFD may not // have a traversable pathname at all (pipe, socket, memfd, ...). // Preserve final fd-link metadata for procfs handling instead of @@ -1996,6 +2028,13 @@ fn resolve_namespace_path_from( final_stat = Some(stat); continue; } + let target = namespace_readlink_raw(proc, host, &candidate)?; + if substituted_path_len(&target, &pending) + .ok_or(Errno::ENAMETOOLONG)? + >= NAMESPACE_PATH_MAX + { + return Err(Errno::ENAMETOOLONG); + } let had_remainder = !pending.is_empty(); if !had_remainder && target.len() > 1 && target.last() == Some(&b'/') { require_directory = true; @@ -2010,6 +2049,12 @@ fn resolve_namespace_path_from( .filter(|part| !part.is_empty()) .map(|part| part.to_vec()) .collect(); + if target_components + .iter() + .any(|component| component.len() > NAMESPACE_NAME_MAX) + { + return Err(Errno::ENAMETOOLONG); + } for part in target_components.into_iter().rev() { pending.push_front(part); } @@ -12282,35 +12327,204 @@ pub fn sys_sysconf(name: i32) -> Result { } } -/// pathconf -- get configurable pathname variable values. -/// -/// Returns POSIX-required compile-time constants for the given name. -/// The path is not validated (we return the same values regardless). -pub fn sys_pathconf(_path: &[u8], name: i32) -> Result { - pathconf_value(name) +fn validate_pathconf_name(name: i32) -> Result<(), Errno> { + if wasm_posix_shared::pathconf::ABI_NAMES + .iter() + .any(|(_, value)| *value == name) + { + Ok(()) + } else { + Err(Errno::EINVAL) + } } -/// fpathconf -- get configurable pathname variable values for an open fd. -pub fn sys_fpathconf(proc: &Process, fd: i32, name: i32) -> Result { - let _ = proc.fd_table.get(fd)?; - pathconf_value(name) -} +fn filesystem_pathconf_value( + name: i32, + supports_symlinks: bool, + timestamp_resolution_ns: Option, +) -> Result, Errno> { + use wasm_posix_shared::pathconf as pc; -fn pathconf_value(name: i32) -> Result { match name { - 1 => Ok(14), // _PC_LINK_MAX - 2 => Ok(13), // _PC_MAX_CANON - 3 => Ok(255), // _PC_MAX_INPUT - 4 => Ok(255), // _PC_NAME_MAX - 5 => Ok(4096), // _PC_PATH_MAX - 6 => Ok(4096), // _PC_PIPE_BUF - 7 => Ok(1), // _PC_CHOWN_RESTRICTED - 8 => Ok(1), // _PC_NO_TRUNC - 9 => Ok(0), // _PC_VDISABLE + pc::LINK_MAX => Ok(None), + pc::NAME_MAX => Ok(Some(NAMESPACE_NAME_MAX as i64)), + pc::PATH_MAX => Ok(Some(NAMESPACE_PATH_MAX as i64)), + // Authorization is enforced by the kernel for every namespace + // backend, including backends without persistent ownership metadata. + pc::CHOWN_RESTRICTED => Ok(Some(1)), + pc::NO_TRUNC => Ok(Some(1)), + pc::SYNC_IO + | pc::PRIO_IO + | pc::FILESIZEBITS + | pc::REC_INCR_XFER_SIZE + | pc::REC_MAX_XFER_SIZE + | pc::REC_MIN_XFER_SIZE + | pc::REC_XFER_ALIGN + | pc::ALLOC_SIZE_MIN + | pc::SYMLINK_MAX + | pc::FALLOC => Ok(None), + pc::POSIX2_SYMLINKS => Ok(supports_symlinks.then_some(1)), + pc::TEXTDOMAIN_MAX => Ok(Some(NAMESPACE_NAME_MAX as i64)), + pc::TIMESTAMP_RESOLUTION => Ok(timestamp_resolution_ns), + pc::MAX_CANON + | pc::MAX_INPUT + | pc::PIPE_BUF + | pc::VDISABLE + | pc::SOCK_MAXBUF + | pc::ASYNC_IO => Err(Errno::EINVAL), _ => Err(Errno::EINVAL), } } +fn terminal_pathconf_value(name: i32) -> Result, Errno> { + use wasm_posix_shared::pathconf as pc; + + match name { + pc::MAX_CANON | pc::MAX_INPUT => Ok(None), + pc::VDISABLE => Ok(Some(0)), + _ => virtual_filesystem_pathconf_value(name), + } +} + +fn virtual_filesystem_pathconf_value(name: i32) -> Result, Errno> { + filesystem_pathconf_value(name, false, None) +} + +/// `pathconf` validates and follows the pathname through the global namespace, +/// then asks the selected backend for values that depend on that filesystem. +/// `None` is a successful indeterminate/unsupported-option result and must be +/// returned to libc as `-1` without changing errno. +pub fn sys_pathconf( + proc: &Process, + host: &mut dyn HostIO, + path: &[u8], + name: i32, +) -> Result, Errno> { + validate_pathconf_name(name)?; + let resolved_entry = resolve_namespace_path(proc, host, path, PathResolveOptions::FOLLOW)?; + if name == wasm_posix_shared::pathconf::ASYNC_IO + && resolved_entry + .stat + .is_some_and(|stat| stat.st_mode & S_IFMT == S_IFREG) + { + // Kandelo's musl implements AIO through guest pthreads over the same + // pread/pwrite/fsync path on every host backend. + return Ok(Some(1)); + } + let resolved = resolved_entry.path; + + if let Some(fd) = match_dev_fd(&resolved) { + return sys_fpathconf(proc, host, fd, name); + } + if let Some(crate::procfs::ProcfsEntry::FdLink(pid, fd)) = + crate::procfs::match_procfs(&resolved, proc.pid) + { + if pid == proc.pid { + return sys_fpathconf(proc, host, fd, name); + } + } + + if resolved == b"/dev/tty" { + let controlling_fd = proc + .fd_table + .iter() + .find_map(|(fd, entry)| { + proc.ofd_table + .get(entry.ofd_ref.0) + .is_some_and(|ofd| ofd.file_type == FileType::PtySlave) + .then_some(fd) + }) + .or_else(|| proc.fd_table.get(0).ok().map(|_| 0)); + return controlling_fd + .ok_or(Errno::ENXIO) + .and_then(|fd| sys_fpathconf(proc, host, fd, name)); + } + if resolved == b"/dev/ptmx" || resolved.starts_with(b"/dev/pts/") { + return terminal_pathconf_value(name); + } + if is_procfs_namespace_path(&resolved) + || (is_devfs_namespace_path(&resolved) && !is_host_backed_devfs_path(&resolved)) + { + return virtual_filesystem_pathconf_value(name); + } + if synthetic_file_content(&resolved).is_some() { + return host.host_pathconf(b"/", name); + } + + host.host_pathconf(&resolved, name) +} + +/// `fpathconf` uses the live OFD/backend identity. It never re-resolves the +/// remembered pathname, so an open file remains queryable after rename or +/// unlink. +pub fn sys_fpathconf( + proc: &Process, + host: &mut dyn HostIO, + fd: i32, + name: i32, +) -> Result, Errno> { + use wasm_posix_shared::pathconf as pc; + + validate_pathconf_name(name)?; + let entry = proc.fd_table.get(fd)?; + let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; + let file_type = ofd.file_type; + let host_handle = ofd.host_handle; + let path = ofd.path.clone(); + + if name == pc::ASYNC_IO + && (file_type == FileType::MemFd + || (file_type == FileType::Regular && host_handle < 0)) + { + return Ok(Some(1)); + } + + if synthetic_file_content(&path).is_some() { + // Synthetic dynamic files live in the root mount's namespace even + // though they have no host handle of their own. Match pathconf and + // the existing statfs/fstatfs policy by querying the root backend. + return host.host_pathconf(b"/", name); + } + + match file_type { + FileType::Pipe => { + if name == pc::PIPE_BUF { + return Ok((host_handle < 0).then_some(crate::pipe::PIPE_BUF as i64)); + } + Err(Errno::EINVAL) + } + FileType::PtyMaster | FileType::PtySlave => terminal_pathconf_value(name), + FileType::Socket => { + if name == pc::SOCK_MAXBUF { + // Socket buffering varies across in-kernel and host/browser + // backends; no authoritative maximum is enforced globally. + Ok(None) + } else { + Err(Errno::EINVAL) + } + } + FileType::MemFd => filesystem_pathconf_value(name, false, None), + FileType::Regular | FileType::Directory | FileType::CharDevice => { + if file_type == FileType::CharDevice + && matches!(path.as_slice(), b"/dev/stdin" | b"/dev/stdout" | b"/dev/stderr") + { + terminal_pathconf_value(name) + } else if is_procfs_namespace_path(&path) + || (is_devfs_namespace_path(&path) && !is_host_backed_devfs_path(&path)) + { + virtual_filesystem_pathconf_value(name) + } else if host_handle >= 0 { + host.host_fpathconf(host_handle, name) + } else { + virtual_filesystem_pathconf_value(name) + } + } + FileType::EventFd | FileType::Epoll | FileType::TimerFd | FileType::SignalFd => { + Err(Errno::EINVAL) + } + } +} + /// ftruncate -- truncate a file to a specified length. pub fn sys_ftruncate( proc: &mut Process, @@ -13498,6 +13712,10 @@ mod tests { seek_end: i64, seek_calls: Vec<(i64, i64, u32)>, stat_size: u64, + pathconf_result: Result, Errno>, + fpathconf_result: Result, Errno>, + pathconf_calls: Vec<(Vec, i32)>, + fpathconf_calls: Vec<(i64, i32)>, } impl MockHostIO { @@ -13537,6 +13755,10 @@ mod tests { seek_end: 0, seek_calls: Vec::new(), stat_size: 1024, + pathconf_result: Ok(Some(255)), + fpathconf_result: Ok(Some(4096)), + pathconf_calls: Vec::new(), + fpathconf_calls: Vec::new(), } } @@ -13727,6 +13949,20 @@ mod tests { .unwrap_or_else(default_statfs)) } + fn host_pathconf(&mut self, path: &[u8], name: i32) -> Result, Errno> { + self.pathconf_calls.push((path.to_vec(), name)); + self.pathconf_result + } + + fn host_fpathconf( + &mut self, + handle: i64, + name: i32, + ) -> Result, Errno> { + self.fpathconf_calls.push((handle, name)); + self.fpathconf_result + } + fn host_mkdir(&mut self, path: &[u8], mode: u32) -> Result<(), Errno> { self.missing_paths.remove(path); self.file_modes @@ -19652,32 +19888,391 @@ mod tests { // ---- pathconf/fpathconf tests ---- + fn namespace_boundary_path(final_component_len: usize, final_is_dir: bool) -> Vec { + let mut path = vec![b'/']; + for index in 0..15 { + if index > 0 { + path.push(b'/'); + } + path.extend(std::iter::repeat_n(b'd', 252)); + path.extend_from_slice(b"dir"); + } + path.push(b'/'); + if final_is_dir { + path.extend(std::iter::repeat_n(b'd', final_component_len - 3)); + path.extend_from_slice(b"dir"); + } else { + path.extend(std::iter::repeat_n(b'f', final_component_len)); + } + path + } + + #[test] + fn test_pathconf_delegates_resolved_path_and_value() { + use wasm_posix_shared::pathconf as pc; + + let proc = Process::new(1); + let mut host = MockHostIO::new(); + host.pathconf_result = Ok(Some(123)); + + assert_eq!( + sys_pathconf(&proc, &mut host, b"/tmp/foo", pc::NAME_MAX), + Ok(Some(123)) + ); + assert_eq!(host.pathconf_calls, vec![(b"/tmp/foo".to_vec(), pc::NAME_MAX)]); + } + + #[test] + fn test_pathconf_preserves_indeterminate_result() { + use wasm_posix_shared::pathconf as pc; + + let proc = Process::new(1); + let mut host = MockHostIO::new(); + host.pathconf_result = Ok(None); + + assert_eq!( + sys_pathconf(&proc, &mut host, b"/tmp/foo", pc::LINK_MAX), + Ok(None) + ); + } + + #[test] + fn test_pathconf_async_io_is_supported_for_regular_files_only() { + use wasm_posix_shared::pathconf as pc; + + let proc = Process::new(1); + let mut host = MockHostIO::new(); + assert_eq!( + sys_pathconf(&proc, &mut host, b"/tmp/foo", pc::ASYNC_IO), + Ok(Some(1)) + ); + assert!(host.pathconf_calls.is_empty()); + + host.pathconf_result = Err(Errno::EINVAL); + assert_eq!( + sys_pathconf(&proc, &mut host, b"/tmp", pc::ASYNC_IO), + Err(Errno::EINVAL) + ); + } + + #[test] + fn test_pathconf_validates_name_before_path() { + let proc = Process::new(1); + let mut host = MockHostIO::new(); + host.set_missing_path(b"/missing"); + + assert_eq!( + sys_pathconf(&proc, &mut host, b"/missing", 999), + Err(Errno::EINVAL) + ); + assert!(host.pathconf_calls.is_empty()); + } + + #[test] + fn test_pathconf_reports_missing_path() { + use wasm_posix_shared::pathconf as pc; + + let proc = Process::new(1); + let mut host = MockHostIO::new(); + host.set_missing_path(b"/missing"); + + assert_eq!( + sys_pathconf(&proc, &mut host, b"/missing", pc::PATH_MAX), + Err(Errno::ENOENT) + ); + assert!(host.pathconf_calls.is_empty()); + } + + #[test] + fn test_namespace_component_limit_is_enforced_in_bytes() { + use wasm_posix_shared::pathconf as pc; + + let proc = Process::new(1); + let mut host = MockHostIO::new(); + let mut accepted = vec![b'/']; + accepted.extend(std::iter::repeat_n(b'a', 255)); + assert_eq!( + sys_pathconf(&proc, &mut host, &accepted, pc::NAME_MAX), + Ok(Some(255)) + ); + + let mut rejected = vec![b'/']; + rejected.extend(std::iter::repeat_n(b'a', 256)); + assert_eq!( + sys_pathconf(&proc, &mut host, &rejected, pc::NAME_MAX), + Err(Errno::ENAMETOOLONG) + ); + + let mut utf8 = vec![b'/']; + for _ in 0..128 { + utf8.extend_from_slice("é".as_bytes()); + } + assert_eq!( + sys_pathconf(&proc, &mut host, &utf8, pc::NAME_MAX), + Err(Errno::ENAMETOOLONG) + ); + } + #[test] - fn test_pathconf_name_max() { - assert_eq!(sys_pathconf(b"/tmp/foo", 4), Ok(255)); // _PC_NAME_MAX + fn test_namespace_path_limit_includes_terminating_nul() { + use wasm_posix_shared::pathconf as pc; + + let proc = Process::new(1); + let mut host = MockHostIO::new(); + host.pathconf_result = Ok(Some(NAMESPACE_PATH_MAX as i64)); + let accepted = namespace_boundary_path(254, false); + assert_eq!(accepted.len(), 4095); + assert_eq!( + sys_pathconf(&proc, &mut host, &accepted, pc::PATH_MAX), + Ok(Some(NAMESPACE_PATH_MAX as i64)) + ); + + let rejected = namespace_boundary_path(255, false); + assert_eq!(rejected.len(), 4096); + assert_eq!( + sys_pathconf(&proc, &mut host, &rejected, pc::PATH_MAX), + Err(Errno::ENAMETOOLONG) + ); } #[test] - fn test_pathconf_pipe_buf() { - assert_eq!(sys_pathconf(b"/tmp/foo", 6), Ok(4096)); // _PC_PIPE_BUF + fn test_namespace_rejects_symlink_expansion_past_path_max() { + use wasm_posix_shared::pathconf as pc; + + let proc = Process::new(1); + let mut host = MockHostIO::new(); + let long_directory = namespace_boundary_path(254, true); + assert_eq!(long_directory.len(), 4095); + host.set_symlink(b"/link", &long_directory); + assert_eq!( + sys_pathconf(&proc, &mut host, b"/link/child", pc::PATH_MAX), + Err(Errno::ENAMETOOLONG) + ); + } + + #[test] + fn test_namespace_allows_short_relative_path_from_deep_cwd() { + use wasm_posix_shared::pathconf as pc; + + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + host.pathconf_result = Ok(Some(NAMESPACE_PATH_MAX as i64)); + proc.cwd = namespace_boundary_path(254, true); + assert_eq!(proc.cwd.len(), 4095); + + assert_eq!( + sys_pathconf(&proc, &mut host, b".", pc::PATH_MAX), + Ok(Some(NAMESPACE_PATH_MAX as i64)) + ); + let mut child = proc.cwd.clone(); + child.extend_from_slice(b"/child"); + assert_eq!( + sys_pathconf(&proc, &mut host, b"child", pc::PATH_MAX), + Ok(Some(NAMESPACE_PATH_MAX as i64)) + ); + assert_eq!( + host.pathconf_calls, + vec![ + (proc.cwd.clone(), pc::PATH_MAX), + (child, pc::PATH_MAX), + ] + ); } #[test] - fn test_pathconf_invalid_name() { - assert_eq!(sys_pathconf(b"/tmp/foo", 999), Err(Errno::EINVAL)); + fn test_fpathconf_uses_live_host_handle_after_path_disappears() { + use wasm_posix_shared::pathconf as pc; + + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_open(&mut proc, &mut host, b"/tmp/foo", O_RDONLY, 0).unwrap(); + host.set_missing_path(b"/tmp/foo"); + host.fpathconf_result = Ok(Some(4096)); + + assert_eq!( + sys_fpathconf(&proc, &mut host, fd, pc::PATH_MAX), + Ok(Some(4096)) + ); + assert_eq!(host.fpathconf_calls, vec![(100, pc::PATH_MAX)]); + assert!(host.pathconf_calls.is_empty()); } #[test] - fn test_fpathconf_valid_fd() { + fn test_fpathconf_invalid_fd_does_not_call_host() { + use wasm_posix_shared::pathconf as pc; + let proc = Process::new(1); - // fd 0 is pre-opened (stdin) - assert_eq!(sys_fpathconf(&proc, 0, 5), Ok(4096)); // _PC_PATH_MAX + let mut host = MockHostIO::new(); + + assert_eq!( + sys_fpathconf(&proc, &mut host, 99, pc::PATH_MAX), + Err(Errno::EBADF) + ); + assert!(host.fpathconf_calls.is_empty()); } #[test] - fn test_fpathconf_invalid_fd() { + fn test_fpathconf_kernel_pipe_reports_pipe_buf_without_host() { + use wasm_posix_shared::pathconf as pc; + + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let (read_fd, _) = sys_pipe(&mut proc).unwrap(); + + assert_eq!( + sys_fpathconf(&proc, &mut host, read_fd, pc::PIPE_BUF), + Ok(Some(crate::pipe::PIPE_BUF as i64)) + ); + assert!(host.fpathconf_calls.is_empty()); + } + + #[test] + fn test_fpathconf_host_pipe_leaves_pipe_buf_indeterminate() { + use wasm_posix_shared::pathconf as pc; + let proc = Process::new(1); - assert_eq!(sys_fpathconf(&proc, 99, 5), Err(Errno::EBADF)); + let mut host = MockHostIO::new(); + + assert_eq!( + sys_fpathconf(&proc, &mut host, 0, pc::PIPE_BUF), + Ok(None) + ); + assert!(host.fpathconf_calls.is_empty()); + } + + #[test] + fn test_fpathconf_async_io_regular_and_memfd_support() { + use wasm_posix_shared::pathconf as pc; + + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + host.fpathconf_result = Ok(Some(1)); + let regular = sys_open(&mut proc, &mut host, b"/tmp/foo", O_RDONLY, 0).unwrap(); + let memfd = sys_memfd_create(&mut proc, b"pathconf", 0).unwrap(); + let (pipe, _) = sys_pipe(&mut proc).unwrap(); + + assert_eq!( + sys_fpathconf(&proc, &mut host, regular, pc::ASYNC_IO), + Ok(Some(1)) + ); + assert_eq!( + sys_fpathconf(&proc, &mut host, memfd, pc::ASYNC_IO), + Ok(Some(1)) + ); + assert_eq!( + sys_fpathconf(&proc, &mut host, pipe, pc::ASYNC_IO), + Err(Errno::EINVAL) + ); + assert_eq!(host.fpathconf_calls, vec![(100, pc::ASYNC_IO)]); + } + + #[test] + fn test_fpathconf_terminal_stdio_reports_terminal_and_namespace_values() { + use wasm_posix_shared::pathconf as pc; + + let proc = terminal_process(1); + let mut host = MockHostIO::new(); + + assert_eq!( + sys_fpathconf(&proc, &mut host, 0, pc::MAX_CANON), + Ok(None) + ); + assert_eq!( + sys_fpathconf(&proc, &mut host, 0, pc::VDISABLE), + Ok(Some(0)) + ); + assert_eq!( + sys_fpathconf(&proc, &mut host, 0, pc::PATH_MAX), + Ok(Some(NAMESPACE_PATH_MAX as i64)) + ); + assert!(host.fpathconf_calls.is_empty()); + } + + #[test] + fn test_pathconf_dev_stdin_uses_terminal_ofd() { + use wasm_posix_shared::pathconf as pc; + + let proc = terminal_process(1); + let mut host = MockHostIO::new(); + + assert_eq!( + sys_pathconf(&proc, &mut host, b"/dev/stdin", pc::VDISABLE), + Ok(Some(0)) + ); + assert!(host.pathconf_calls.is_empty()); + } + + #[test] + fn test_pathconf_dev_tty_matches_controlling_stdio_kind() { + use wasm_posix_shared::pathconf as pc; + + let captured = Process::new(1); + let mut captured_host = MockHostIO::new(); + assert_eq!( + sys_pathconf(&captured, &mut captured_host, b"/dev/tty", pc::PIPE_BUF), + Ok(None) + ); + assert_eq!( + sys_pathconf(&captured, &mut captured_host, b"/dev/tty", pc::VDISABLE), + Err(Errno::EINVAL) + ); + + let terminal = terminal_process(2); + let mut terminal_host = MockHostIO::new(); + assert_eq!( + sys_pathconf(&terminal, &mut terminal_host, b"/dev/tty", pc::VDISABLE), + Ok(Some(0)) + ); + } + + #[test] + fn test_fpathconf_socket_buffer_limit_is_indeterminate() { + use wasm_posix_shared::pathconf as pc; + use wasm_posix_shared::socket::{AF_UNIX, SOCK_STREAM}; + + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let (fd, _) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + + assert_eq!( + sys_fpathconf(&proc, &mut host, fd, pc::SOCK_MAXBUF), + Ok(None) + ); + assert!(host.fpathconf_calls.is_empty()); + } + + #[test] + fn test_synthetic_pathconf_and_fpathconf_use_root_backend() { + use wasm_posix_shared::pathconf as pc; + + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + host.pathconf_result = Ok(Some(777)); + + assert_eq!( + sys_pathconf(&proc, &mut host, b"/etc/mtab", pc::PATH_MAX), + Ok(Some(777)) + ); + let fd = sys_open(&mut proc, &mut host, b"/etc/mtab", O_RDONLY, 0).unwrap(); + assert_eq!( + sys_fpathconf(&proc, &mut host, fd, pc::PATH_MAX), + Ok(Some(777)) + ); + assert_eq!( + host.pathconf_calls, + vec![(b"/".to_vec(), pc::PATH_MAX), (b"/".to_vec(), pc::PATH_MAX)] + ); + assert!(host.fpathconf_calls.is_empty()); + } + + #[test] + fn test_pathconf_name_table_is_complete_and_unique() { + let mut values = std::collections::HashSet::new(); + for &(_, value) in wasm_posix_shared::pathconf::ABI_NAMES { + assert!(validate_pathconf_name(value).is_ok()); + assert!(values.insert(value), "duplicate pathconf value {value}"); + } + assert_eq!(values.len(), 24); } // ---- getsockname/getpeername tests ---- diff --git a/crates/kernel/src/terminal.rs b/crates/kernel/src/terminal.rs index e0a8d01d8..f2343d400 100644 --- a/crates/kernel/src/terminal.rs +++ b/crates/kernel/src/terminal.rs @@ -178,6 +178,14 @@ impl TerminalState { self.c_lflag & ICANON != 0 } + #[inline] + fn control_char_matches(&self, index: usize, byte: u8) -> bool { + // Kandelo's guest headers define _POSIX_VDISABLE as NUL. A zero + // control-character slot is disabled and must not make an input NUL + // act as VINTR/VEOL/etc. + self.c_cc[index] != 0 && self.c_cc[index] == byte + } + /// Process a byte through the line discipline. /// Returns echo bytes and an optional signal number if ISIG matched. /// ISIG is checked independently of ICANON — signal characters generate @@ -201,11 +209,11 @@ impl TerminalState { // This works in both canonical and raw modes. if self.c_lflag & ISIG != 0 { use wasm_posix_shared::signal; - let sig = if byte == self.c_cc[VINTR] { + let sig = if self.control_char_matches(VINTR, byte) { Some(signal::SIGINT) - } else if byte == self.c_cc[VQUIT] { + } else if self.control_char_matches(VQUIT, byte) { Some(signal::SIGQUIT) - } else if byte == self.c_cc[VSUSP] { + } else if self.control_char_matches(VSUSP, byte) { Some(signal::SIGTSTP) } else { None @@ -233,7 +241,7 @@ impl TerminalState { } // Check for VERASE (backspace/delete) - if byte == self.c_cc[VERASE] { + if self.control_char_matches(VERASE, byte) { if !self.line_buffer.is_empty() { self.line_buffer.pop(); if do_echo && self.c_lflag & ECHOE != 0 { @@ -245,7 +253,7 @@ impl TerminalState { } // Check for VKILL (kill line, ^U) - if byte == self.c_cc[VKILL] { + if self.control_char_matches(VKILL, byte) { if do_echo && self.c_lflag & ECHOK != 0 { // Erase the whole line from display for _ in 0..self.line_buffer.len() { @@ -257,7 +265,7 @@ impl TerminalState { } // Check for VEOF (^D) - if byte == self.c_cc[VEOF] { + if self.control_char_matches(VEOF, byte) { // Flush current line buffer without adding the EOF character self.cooked_buffer.extend_from_slice(&self.line_buffer); self.line_buffer.clear(); @@ -265,7 +273,7 @@ impl TerminalState { } // Newline or VEOL: complete the line - if byte == b'\n' || byte == self.c_cc[VEOL] { + if byte == b'\n' || self.control_char_matches(VEOL, byte) { self.line_buffer.push(byte); self.cooked_buffer.extend_from_slice(&self.line_buffer); self.line_buffer.clear(); @@ -352,6 +360,20 @@ mod tests { assert_eq!(&buf[..n], b"hello\n"); } + #[test] + fn disabled_zero_control_char_does_not_consume_input_nul() { + let mut ts = TerminalState::new(); + + let (_, signal) = ts.process_input_byte(0); + assert_eq!(signal, None); + assert!(!ts.has_cooked_data()); + + ts.process_input_byte(b'\n'); + let mut buf = [0u8; 4]; + let n = ts.read_cooked(&mut buf); + assert_eq!(&buf[..n], b"\0\n"); + } + #[test] fn test_line_buffer_cr_to_nl() { let mut ts = TerminalState::new(); diff --git a/crates/kernel/src/wasm_api.rs b/crates/kernel/src/wasm_api.rs index e20750fdb..3752ba439 100644 --- a/crates/kernel/src/wasm_api.rs +++ b/crates/kernel/src/wasm_api.rs @@ -39,6 +39,8 @@ unsafe extern "C" { fn host_stat(path_ptr: *const u8, path_len: u32, stat_ptr: *mut u8) -> i32; fn host_lstat(path_ptr: *const u8, path_len: u32, stat_ptr: *mut u8) -> i32; fn host_statfs(path_ptr: *const u8, path_len: u32, statfs_ptr: *mut u8) -> i32; + fn host_pathconf(path_ptr: *const u8, path_len: u32, name: i32, value_ptr: *mut i64) -> i32; + fn host_fpathconf(handle: i64, name: i32, value_ptr: *mut i64) -> i32; fn host_mkdir(path_ptr: *const u8, path_len: u32, mode: u32) -> i32; fn host_rmdir(path_ptr: *const u8, path_len: u32) -> i32; fn host_unlink(path_ptr: *const u8, path_len: u32) -> i32; @@ -372,6 +374,27 @@ impl HostIO for WasmHostIO { Ok(statfs) } + fn host_pathconf(&mut self, path: &[u8], name: i32) -> Result, Errno> { + let mut value = -1i64; + let result = unsafe { + host_pathconf( + path.as_ptr(), + path.len() as u32, + name, + &mut value as *mut i64, + ) + }; + i32_to_result(result)?; + Ok((value != -1).then_some(value)) + } + + fn host_fpathconf(&mut self, handle: i64, name: i32) -> Result, Errno> { + let mut value = -1i64; + let result = unsafe { host_fpathconf(handle, name, &mut value as *mut i64) }; + i32_to_result(result)?; + Ok((value != -1).then_some(value)) + } + fn host_mkdir(&mut self, path: &[u8], mode: u32) -> Result<(), Errno> { let result = unsafe { host_mkdir(path.as_ptr(), path.len() as u32, mode) }; i32_to_result(result) @@ -3279,9 +3302,9 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { // SYS_PATHCONF let p = a1 as *const u8; let len = unsafe { cstr_len(p) }; - kernel_pathconf(p, len as u32, a2) as i32 + kernel_pathconf(p, len as u32, a2, a3 as *mut i64) } - 113 => kernel_fpathconf(a1, a2) as i32, // SYS_FPATHCONF + 113 => kernel_fpathconf(a1, a2, a3 as *mut i64), // SYS_FPATHCONF // setreuid/setregid — map to setresuid/setresgid with -1 for saved ID 215 => kernel_setresuid(a1 as u32, a2 as u32, 0xFFFFFFFF), // SYS_SETREUID @@ -9947,27 +9970,44 @@ pub extern "C" fn kernel_rt_sigtimedwait(mask_lo: u32, mask_hi: u32, timeout_ms: /// pathconf -- get configurable pathname variable for a path. #[unsafe(no_mangle)] -pub extern "C" fn kernel_pathconf(path_ptr: *const u8, path_len: u32, name: i32) -> i64 { +pub extern "C" fn kernel_pathconf( + path_ptr: *const u8, + path_len: u32, + name: i32, + value_ptr: *mut i64, +) -> i32 { + if path_ptr.is_null() || value_ptr.is_null() { + return -(Errno::EFAULT as i32); + } let (_gkl, proc) = unsafe { get_process() }; let path = unsafe { core::slice::from_raw_parts(path_ptr, path_len as usize) }; - let result = match syscalls::sys_pathconf(path, name) { - Ok(v) => v, - Err(e) => -(e as i64), - }; let mut host = WasmHostIO; + let result = match syscalls::sys_pathconf(proc, &mut host, path, name) { + Ok(value) => { + unsafe { core::ptr::write_unaligned(value_ptr, value.unwrap_or(-1)) }; + 0 + } + Err(e) => -(e as i32), + }; deliver_pending_signals(proc, &mut host); result } /// fpathconf -- get configurable pathname variable for an open fd. #[unsafe(no_mangle)] -pub extern "C" fn kernel_fpathconf(fd: i32, name: i32) -> i64 { +pub extern "C" fn kernel_fpathconf(fd: i32, name: i32, value_ptr: *mut i64) -> i32 { + if value_ptr.is_null() { + return -(Errno::EFAULT as i32); + } let (_gkl, proc) = unsafe { get_process() }; - let result = match syscalls::sys_fpathconf(proc, fd, name) { - Ok(v) => v, - Err(e) => -(e as i64), - }; let mut host = WasmHostIO; + let result = match syscalls::sys_fpathconf(proc, &mut host, fd, name) { + Ok(value) => { + unsafe { core::ptr::write_unaligned(value_ptr, value.unwrap_or(-1)) }; + 0 + } + Err(e) => -(e as i32), + }; deliver_pending_signals(proc, &mut host); result } diff --git a/crates/shared/src/host_abi.rs b/crates/shared/src/host_abi.rs index d28dec487..4831b9305 100644 --- a/crates/shared/src/host_abi.rs +++ b/crates/shared/src/host_abi.rs @@ -44,6 +44,8 @@ pub struct SyscallArgDesc { pub size: SyscallArgSize, /// Whether a null pointer is a valid request to omit this argument. pub nullable: bool, + /// Whether a non-C-string pointer must be non-null. + pub required: bool, /// Extra bytes to copy back when an output `Arg`-sized buffer's copied /// length is based on the syscall return value. `msgrcv` returns only /// `mtext` length, but the scratch buffer also includes the leading mtype. @@ -108,6 +110,7 @@ macro_rules! desc { direction: SyscallArgDirection::$direction, size: $size, nullable: false, + required: false, copy_retval_add: 0, } }; @@ -117,6 +120,17 @@ macro_rules! desc { direction: SyscallArgDirection::$direction, size: $size, nullable: true, + required: false, + copy_retval_add: 0, + } + }; + ($arg_index:expr, $direction:ident, $size:expr, required) => { + SyscallArgDesc { + arg_index: $arg_index, + direction: SyscallArgDirection::$direction, + size: $size, + nullable: false, + required: true, copy_retval_add: 0, } }; @@ -126,6 +140,7 @@ macro_rules! desc { direction: SyscallArgDirection::$direction, size: $size, nullable: false, + required: false, copy_retval_add: $copy_retval_add, } }; @@ -307,6 +322,17 @@ pub const SYSCALL_ARG_DESCRIPTORS: &[SyscallArgDescriptor] = &[ [desc!(0, In, cstring!()), desc!(1, Out, arg!(2)),] ), entry!(Syscall::Sigsuspend as u32, [desc!(0, In, fixed!(8))]), + entry!( + Syscall::Pathconf as u32, + [ + desc!(0, In, cstring!()), + desc!(2, Out, fixed!(8), required), + ] + ), + entry!( + Syscall::Fpathconf as u32, + [desc!(2, Out, fixed!(8), required)] + ), entry!( Syscall::Getsockname as u32, [desc!(1, Out, deref!(2)), desc!(2, InOut, fixed!(4)),] @@ -533,6 +559,20 @@ mod tests { assert_eq!(utimensat_path.size, SyscallArgSize::CString); assert!(utimensat_path.nullable); + let pathconf = find(Syscall::Pathconf as u32).args; + assert_eq!(pathconf[0].size, SyscallArgSize::CString); + assert!(!pathconf[0].nullable); + assert_eq!(pathconf[1].arg_index, 2); + assert_eq!(pathconf[1].direction, SyscallArgDirection::Out); + assert_eq!(pathconf[1].size, SyscallArgSize::Fixed { size: 8 }); + assert!(pathconf[1].required); + + let fpathconf = find(Syscall::Fpathconf as u32).args[0]; + assert_eq!(fpathconf.arg_index, 2); + assert_eq!(fpathconf.direction, SyscallArgDirection::Out); + assert_eq!(fpathconf.size, SyscallArgSize::Fixed { size: 8 }); + assert!(fpathconf.required); + let semop = find(extra_syscalls::SYS_SEMOP).args[0].size; assert_eq!( semop, diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 9ee0fc817..966024936 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -317,6 +317,65 @@ impl Syscall { } } +/// ABI-visible names accepted by `pathconf()` and `fpathconf()`. +/// +/// These values are consumed by libc, the kernel, and the generated host +/// bindings. Keep the numeric contract centralized here rather than copying +/// the `_PC_*` numbering into each layer. +pub mod pathconf { + pub const LINK_MAX: i32 = 0; + pub const MAX_CANON: i32 = 1; + pub const MAX_INPUT: i32 = 2; + pub const NAME_MAX: i32 = 3; + pub const PATH_MAX: i32 = 4; + pub const PIPE_BUF: i32 = 5; + pub const CHOWN_RESTRICTED: i32 = 6; + pub const NO_TRUNC: i32 = 7; + pub const VDISABLE: i32 = 8; + pub const SYNC_IO: i32 = 9; + pub const ASYNC_IO: i32 = 10; + pub const PRIO_IO: i32 = 11; + pub const SOCK_MAXBUF: i32 = 12; + pub const FILESIZEBITS: i32 = 13; + pub const REC_INCR_XFER_SIZE: i32 = 14; + pub const REC_MAX_XFER_SIZE: i32 = 15; + pub const REC_MIN_XFER_SIZE: i32 = 16; + pub const REC_XFER_ALIGN: i32 = 17; + pub const ALLOC_SIZE_MIN: i32 = 18; + pub const SYMLINK_MAX: i32 = 19; + pub const POSIX2_SYMLINKS: i32 = 20; + pub const FALLOC: i32 = 21; + pub const TEXTDOMAIN_MAX: i32 = 22; + pub const TIMESTAMP_RESOLUTION: i32 = 23; + + pub const ABI_NAMES: &[(&str, i32)] = &[ + ("LINK_MAX", LINK_MAX), + ("MAX_CANON", MAX_CANON), + ("MAX_INPUT", MAX_INPUT), + ("NAME_MAX", NAME_MAX), + ("PATH_MAX", PATH_MAX), + ("PIPE_BUF", PIPE_BUF), + ("CHOWN_RESTRICTED", CHOWN_RESTRICTED), + ("NO_TRUNC", NO_TRUNC), + ("VDISABLE", VDISABLE), + ("SYNC_IO", SYNC_IO), + ("ASYNC_IO", ASYNC_IO), + ("PRIO_IO", PRIO_IO), + ("SOCK_MAXBUF", SOCK_MAXBUF), + ("FILESIZEBITS", FILESIZEBITS), + ("REC_INCR_XFER_SIZE", REC_INCR_XFER_SIZE), + ("REC_MAX_XFER_SIZE", REC_MAX_XFER_SIZE), + ("REC_MIN_XFER_SIZE", REC_MIN_XFER_SIZE), + ("REC_XFER_ALIGN", REC_XFER_ALIGN), + ("ALLOC_SIZE_MIN", ALLOC_SIZE_MIN), + ("SYMLINK_MAX", SYMLINK_MAX), + ("POSIX2_SYMLINKS", POSIX2_SYMLINKS), + ("FALLOC", FALLOC), + ("TEXTDOMAIN_MAX", TEXTDOMAIN_MAX), + ("TIMESTAMP_RESOLUTION", TIMESTAMP_RESOLUTION), + ]; +} + /// Status of the shared-memory syscall channel. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u32)] diff --git a/docs/abi-versioning.md b/docs/abi-versioning.md index d0f52d57e..ab628a0f3 100644 --- a/docs/abi-versioning.md +++ b/docs/abi-versioning.md @@ -24,7 +24,8 @@ kernel. Specifically, any of the following requires an `ABI_VERSION` bump: - Removing, renaming, or reassigning a syscall number. - Changing an existing syscall argument descriptor used by the host for pointer marshalling, including direction, size source, multipliers, - fixed byte lengths, pointer nullability, or return-value copy adjustments. + fixed byte lengths, pointer nullability/requiredness, or return-value copy + adjustments. - Changing the channel header layout (field offsets or sizes in [`crates/shared/src/lib.rs`](../crates/shared/src/lib.rs) `channel` module). @@ -43,6 +44,9 @@ kernel. Specifically, any of the following requires an `ABI_VERSION` bump: including the wasm32 cancellation-point `__syscall_cp` path. These are not currently visible in the structural snapshot, but stale objects and archives can otherwise link with incompatible Wasm function signatures. +- Adding or changing a required kernel-Wasm host import. Kernel imports are not + yet present in the structural snapshot, so reviewers must track this surface + explicitly and coordinate the host implementation in the same ABI epoch. - Changing the name, version, encoding, or role semantics of the `kandelo.wpk_fork.capabilities` custom section. The host uses these claims to decide whether a main/side-module pair can safely coordinate fork replay. @@ -114,8 +118,10 @@ captures: the core enum. - `syscall_arg_descriptors` — host marshalling descriptors for pointer arguments, including direction, size source, size multipliers/additions, - fixed byte lengths, pointer nullability, and any return-value-based - copy-back adjustment. + fixed byte lengths, pointer nullability/requiredness, and any + return-value-based copy-back adjustment. +- `pathconf_names` — the shared numeric `_PC_*` vocabulary consumed by the + kernel, generated host bindings, and libc wrappers. - `host_adapter` — Rust-owned boot manifest metadata consumed by host adapters: manifest layout, host adapter protocol version, required worker feature bits, and required/optional kernel exports. diff --git a/docs/posix-status.md b/docs/posix-status.md index c311e6cdc..46cadaa0c 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -355,7 +355,7 @@ shortcuts. | `/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/tty` | Full | Controlling terminal. Opens the session's controlling PTY slave (ENXIO if none). | +| `/dev/tty` | Partial | Uses the first open PTY-slave OFD as the current controlling-terminal heuristic. When none is open, it currently falls back to fd 0 rather than returning ENXIO; `pathconf()` follows that same OFD selection and therefore does not advertise terminal variables for the captured, pipe-backed case. | | `/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. | | `/dev/fb0` | Full | Linux fbdev framebuffer. Single-open (`EBUSY` for second opener). 640×400 BGRA32 packed-pixel. ioctls: `FBIOGET_VSCREENINFO`, `FBIOGET_FSCREENINFO`, `FBIOPAN_DISPLAY` (no-op success), `FBIOPUT_VSCREENINFO` (validates geometry). `mmap` returns a region in process memory and notifies the host (`bind_framebuffer` callback) so the browser canvas can mirror pixels. `munmap`/`exit`/`exec` discard the image mapping; a surviving fd retains device ownership across exec. Ownership is released after both the final fd and any live mapping are gone, since a mapping remains valid after `close()`. Linux-VT keyboard ioctls (`KDGKBTYPE`/`KDGKBMODE`/`KDSKBMODE`) accepted with sensible defaults so fbDOOM-style software works unmodified. | @@ -383,8 +383,8 @@ All virtual devices return synthetic `stat()` with `S_IFCHR | 0666`, determinist | `getrlimit()` | Full | Returns (soft, hard) resource limits. Defaults: NOFILE=(1024,4096), STACK=(8MB,infinity), others infinity. | | `setrlimit()` | Partial | Sets resource limits and validates soft <= hard. RLIMIT_NOFILE updates the fd-table ceiling. RLIMIT_FSIZE covers regular-file and memfd write/pwrite, vectored, transfer, truncate, and mode-0 fallocate paths with partial-to-limit results and thread-directed SIGXFSZ only when no byte can be written. Other resource limits remain advisory or unsupported. | | `getrusage()` | Partial | Returns zeroed rusage struct (144 bytes). RUSAGE_SELF and RUSAGE_CHILDREN supported. No actual resource tracking in Wasm. | -| `pathconf()` | Full | Returns POSIX compile-time constants: _PC_NAME_MAX=255, _PC_PATH_MAX=4096, _PC_PIPE_BUF=4096, _PC_LINK_MAX=14, etc. | -| `fpathconf()` | Full | Same as pathconf() but validates fd exists first. Returns EBADF for invalid fd. | +| `pathconf()` | Partial | Resolves the real namespace path and queries the selected backend. The common resolver enforces byte-based `_PC_NAME_MAX=255`, `_PC_PATH_MAX=4096` for caller and symlink-substituted pathnames (including the terminating NUL), and `_PC_NO_TRUNC=1`; a relative pathname is not charged for the process's internal absolute CWD prefix. `_PC_CHOWN_RESTRICTED=1` reflects the kernel authorization gate. Regular files report thread-backed AIO support. Symlink and timestamp answers reflect the backend (HostFS/MemoryFS timestamps are millisecond-granularity; OPFS reports indeterminate). Invalid names and unsupported file-type associations return EINVAL; valid indeterminate or unsupported options return -1 without changing errno. | +| `fpathconf()` | Partial | Uses the live OFD/backend identity rather than a remembered pathname, so renamed or unlinked open files remain queryable. Invalid fds return EBADF. Kernel pipes report `_PC_PIPE_BUF=4096`; host-backed captured stdio leaves it indeterminate because atomicity through that boundary is not yet proven. Terminal buffers are currently unbounded, `_PC_VDISABLE=0`, and socket maximum buffering is indeterminate. | | `getsockname()` | Partial | Returns stored local addresses for AF_UNIX and AF_INET/AF_INET6 stream or datagram sockets. UDP ephemeral ports, loopback/INADDR_ANY binds, and accepted INADDR_ANY connect outcomes are covered by Sortix UDP tests. External-route local address selection remains unsupported without a HostIO networking backend. | | `getpeername()` | Full | Returns stored peer address for connected sockets. Returns ENOTCONN for unconnected. | @@ -405,7 +405,7 @@ Systematic audit of all subsystems against POSIX specifications. Gaps are catego | Gap | Subsystem | Description | |-----|-----------|-------------| | **EINTR partially implemented** | all | read, write, recv, poll, select return EINTR when a signal is pending during a blocking wait. close() and other non-blocking syscalls do not check. Tied to signal handler invocation gap. | -| ~~**PIPE_BUF atomicity not enforced**~~ | pipe | **Resolved.** Syscalls are serialized through the kernel, so concurrent writes ≤ PIPE_BUF cannot interleave. | +| **PIPE_BUF guarantee at host-backed stdio boundary** | pipe / host | In-kernel pipes guarantee atomic writes through 4096 bytes and report that value from `fpathconf()`. Captured stdio uses host-backed pipe OFDs; its callback/native-write boundary has not been proven all-or-nothing through the compile-time `PIPE_BUF` value, so `fpathconf()` reports the limit as indeterminate. Do not treat the global `` promise as fully reconciled until that boundary is enforced or stdio is modeled differently. | | ~~**O_APPEND not atomic**~~ | write | **Resolved.** Syscalls are serialized through the kernel, so seek-to-end + write cannot be interrupted by another process. | | ~~**sigaction() missing sa_flags**~~ | signals | **Resolved.** SA_RESTART supported (auto-restart blocking syscalls). sa_flags and sa_mask stored. SA_SIGINFO handler delivery with siginfo_t. SA_NOCLDWAIT auto-reaps children. SA_NOCLDSTOP accepted but not acted upon (no job control). | | ~~**No signal queuing**~~ | signals | **Resolved.** RT signals (32-63) are now queued in a VecDeque; standard signals (1-31) remain coalesced per POSIX. | diff --git a/examples/pathconf_test.c b/examples/pathconf_test.c new file mode 100644 index 000000000..3cb1742cb --- /dev/null +++ b/examples/pathconf_test.c @@ -0,0 +1,106 @@ +#include +#include +#include +#include +#include +#include +#include + +#ifdef FILESIZEBITS +#error "FILESIZEBITS must remain undefined when the maximum varies by backend" +#endif + +#if _POSIX_ASYNCHRONOUS_IO <= 0 +#error "thread-backed asynchronous I/O must remain advertised" +#endif + +#if _POSIX_PRIORITIZED_IO != -1 +#error "prioritized I/O is not supported" +#endif + +#if _POSIX_SYNCHRONIZED_IO != -1 +#error "synchronized I/O is not supported" +#endif + +static int check(int condition, const char *message) +{ + if (condition) return 0; + fprintf(stderr, "pathconf failure: %s (errno=%d)\n", message, errno); + return 1; +} + +int main(void) +{ + int failed = 0; + long value; + + errno = E2BIG; + value = pathconf("/", _PC_PATH_MAX); + failed |= check(value == 4096 && errno == E2BIG, + "finite pathconf result preserves errno"); + + errno = E2BIG; + value = pathconf("/", _PC_LINK_MAX); + failed |= check(value == -1 && errno == E2BIG, + "indeterminate pathconf result preserves errno"); + + errno = 0; + value = pathconf("/definitely-missing", _PC_PATH_MAX); + failed |= check(value == -1 && errno == ENOENT, + "missing path reports ENOENT"); + + errno = 0; + value = pathconf("/", 999); + failed |= check(value == -1 && errno == EINVAL, + "invalid name reports EINVAL"); + + errno = 0; + value = fpathconf(-1, _PC_PATH_MAX); + failed |= check(value == -1 && errno == EBADF, + "invalid descriptor reports EBADF"); + + const char *path = "/tmp/pathconf-test"; + int fd = open(path, O_CREAT | O_RDWR | O_TRUNC, 0600); + failed |= check(fd >= 0, "create test file"); + if (fd >= 0) { + failed |= check(pathconf(path, _PC_ASYNC_IO) > 0, + "regular pathname reports asynchronous I/O"); + failed |= check(unlink(path) == 0, "unlink open test file"); + errno = E2BIG; + value = fpathconf(fd, _PC_PATH_MAX); + failed |= check(value == 4096 && errno == E2BIG, + "fpathconf uses live descriptor after unlink"); + failed |= check(fpathconf(fd, _PC_ASYNC_IO) > 0, + "regular descriptor reports asynchronous I/O"); + close(fd); + } + + unsigned char unaligned_storage[16] = {0}; + long raw = syscall(SYS_pathconf, "/", _PC_PATH_MAX, + unaligned_storage + 1); + int64_t raw_value = 0; + memcpy(&raw_value, unaligned_storage + 1, sizeof(raw_value)); + failed |= check(raw == 0 && raw_value == 4096, + "unaligned raw output pointer succeeds"); + + errno = 0; + raw = syscall(SYS_pathconf, "/", _PC_PATH_MAX, (void *)0); + failed |= check(raw == -1 && errno == EFAULT, + "null raw output pointer reports EFAULT"); + + for (uintptr_t remaining = 1; remaining < sizeof(int64_t); remaining++) { + uintptr_t memory_end = + (uintptr_t)__builtin_wasm_memory_size(0) * 65536u; + errno = 0; + raw = syscall(SYS_pathconf, "/", _PC_PATH_MAX, + (void *)(memory_end - remaining)); + failed |= check(raw == -1 && errno == EFAULT, + "out-of-range raw output pointer reports EFAULT"); + failed |= check(getpid() > 0, + "worker remains live after rejected output pointer"); + } + + if (failed) return 1; + puts("PATHCONF_PASS"); + return 0; +} diff --git a/host/src/browser.ts b/host/src/browser.ts index e294063fd..9b81f8887 100644 --- a/host/src/browser.ts +++ b/host/src/browser.ts @@ -9,7 +9,16 @@ export { SharedPipeBuffer } from "./shared-pipe-buffer"; export { BrowserWorkerAdapter } from "./worker-adapter-browser"; export { centralizedWorkerMain, centralizedThreadWorkerMain, patchWasmForThread } from "./worker-main"; export type { MessagePort as WorkerMessagePort } from "./worker-main"; -export type { KernelConfig, PlatformIO, StatResult, StatfsResult } from "./types"; +export type { + KernelConfig, + PathconfValue, + PlatformIO, + StatResult, + StatfsResult, +} from "./types"; +export { PATHCONF_NAMES } from "./generated/abi"; +export { filesystemPathconf } from "./pathconf"; +export type { PathconfProfile } from "./pathconf"; export type { WorkerAdapter, WorkerHandle } from "./worker-adapter"; export type { HostDiagnostic } from "./host-diagnostic"; export type { diff --git a/host/src/generated/abi.ts b/host/src/generated/abi.ts index c547d81ad..df5f0afd1 100644 --- a/host/src/generated/abi.ts +++ b/host/src/generated/abi.ts @@ -329,6 +329,33 @@ export const ABI_SYSCALLS = { ThreadCancel: 415, } as const; +export const PATHCONF_NAMES = { + LINK_MAX: 0, + MAX_CANON: 1, + MAX_INPUT: 2, + NAME_MAX: 3, + PATH_MAX: 4, + PIPE_BUF: 5, + CHOWN_RESTRICTED: 6, + NO_TRUNC: 7, + VDISABLE: 8, + SYNC_IO: 9, + ASYNC_IO: 10, + PRIO_IO: 11, + SOCK_MAXBUF: 12, + FILESIZEBITS: 13, + REC_INCR_XFER_SIZE: 14, + REC_MAX_XFER_SIZE: 15, + REC_MIN_XFER_SIZE: 16, + REC_XFER_ALIGN: 17, + ALLOC_SIZE_MIN: 18, + SYMLINK_MAX: 19, + POSIX2_SYMLINKS: 20, + FALLOC: 21, + TEXTDOMAIN_MAX: 22, + TIMESTAMP_RESOLUTION: 23, +} as const; + export const ABI_SYSCALL_NAMES: Record = { 1: "open", 2: "close", @@ -556,6 +583,7 @@ export interface SyscallArgDesc { direction: SyscallArgDirection; size: SyscallArgSizeSpec; nullable?: boolean; + required?: boolean; copyRetvalAdd?: number; } @@ -771,6 +799,13 @@ export const SYSCALL_ARGS: Record = { 110: [ { argIndex: 0, direction: "in", size: { type: "fixed", size: 8 } }, ], + 112: [ + { argIndex: 0, direction: "in", size: { type: "cstring" } }, + { argIndex: 2, direction: "out", size: { type: "fixed", size: 8 }, required: true }, + ], + 113: [ + { argIndex: 2, direction: "out", size: { type: "fixed", size: 8 }, required: true }, + ], 114: [ { argIndex: 1, direction: "out", size: { type: "deref", argIndex: 2 } }, { argIndex: 2, direction: "inout", size: { type: "fixed", size: 4 } }, diff --git a/host/src/index.ts b/host/src/index.ts index c8164c40e..ae8e16de6 100644 --- a/host/src/index.ts +++ b/host/src/index.ts @@ -13,7 +13,17 @@ export type { LockInfo } from "./shared-lock-table"; export { NodeWorkerAdapter, MockWorkerAdapter, MockWorkerHandle } from "./worker-adapter"; export { centralizedWorkerMain, centralizedThreadWorkerMain } from "./worker-main"; export type { MessagePort as WorkerMessagePort } from "./worker-main"; -export type { KernelConfig, PlatformIO, StatResult, StatfsResult, NetworkIO } from "./types"; +export type { + KernelConfig, + NetworkIO, + PathconfValue, + PlatformIO, + StatResult, + StatfsResult, +} from "./types"; +export { PATHCONF_NAMES } from "./generated/abi"; +export { filesystemPathconf } from "./pathconf"; +export type { PathconfProfile } from "./pathconf"; export { TcpNetworkBackend, FetchNetworkBackend } from "./networking"; export type { FetchBackendOptions, HttpRequest, HttpResponse } from "./networking"; export type { WorkerAdapter, WorkerHandle } from "./worker-adapter"; diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index b28cb0861..5e91896e3 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -152,6 +152,18 @@ function cstringCopySize( }; } +function isValidMemoryRange( + memory: Uint8Array, + ptr: number, + size: number, +): boolean { + return Number.isSafeInteger(ptr) + && ptr > 0 + && Number.isSafeInteger(size) + && size >= 0 + && ptr <= memory.length - size; +} + /** * Maximum combined exec argv + environment representation: UTF-8 strings, * their terminating NUL bytes, and one source-width pointer per entry plus @@ -3204,10 +3216,20 @@ export class CentralizedKernelWorker { for (const desc of argDescs) { const ptr = origArgs[desc.argIndex]; - if ( - ptr === 0 - && (desc.size.type !== "cstring" || desc.nullable === true) - ) { + if (ptr === 0) { + const required = desc.required === true + || (desc.size.type === "cstring" && desc.nullable !== true); + if (required) { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EFAULT, + ); + return; + } continue; } @@ -3239,6 +3261,17 @@ export class CentralizedKernelWorker { // Dereference: arg is a pointer to a u32 value (e.g. socklen_t*) const derefPtr = origArgs[desc.size.argIndex]; if (derefPtr === 0) continue; + if (!isValidMemoryRange(processMem, derefPtr, 4)) { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EFAULT, + ); + return; + } size = processMem[derefPtr] | (processMem[derefPtr + 1] << 8) | (processMem[derefPtr + 2] << 16) | (processMem[derefPtr + 3] << 24); } else { @@ -3259,6 +3292,18 @@ export class CentralizedKernelWorker { } } + if (!isValidMemoryRange(processMem, ptr, size)) { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EFAULT, + ); + return; + } + const kernelPtr = dataStart + dataOffset; // Copy input data from process to kernel @@ -3273,8 +3318,10 @@ export class CentralizedKernelWorker { adjustedArgs[desc.argIndex] = kernelPtr; dataOffset += size; - // Align to 4 bytes for next allocation - dataOffset = (dataOffset + 3) & ~3; + // Kernel exports may dereference i64-bearing structs and scalar output + // slots directly. Keep every following allocation eight-byte aligned; + // CH_DATA itself is eight-byte aligned. + dataOffset = (dataOffset + 7) & ~7; } } @@ -3850,7 +3897,7 @@ export class CentralizedKernelWorker { // with zeros when the target isn't a symlink). if (desc.direction === "out" && retVal < 0) { outOffset += size; - outOffset = (outOffset + 3) & ~3; + outOffset = (outOffset + 7) & ~7; continue; } let copySize = size; @@ -3868,7 +3915,7 @@ export class CentralizedKernelWorker { } outOffset += size; - outOffset = (outOffset + 3) & ~3; + outOffset = (outOffset + 7) & ~7; } } diff --git a/host/src/kernel.ts b/host/src/kernel.ts index 93e7379d5..761ead4bd 100644 --- a/host/src/kernel.ts +++ b/host/src/kernel.ts @@ -542,6 +542,12 @@ export class WasmPosixKernel { host_statfs: (pathPtr: bigint, pathLen: number, statfsPtr: bigint): number => { return this.hostStatfs(Number(pathPtr), pathLen, Number(statfsPtr)); }, + host_pathconf: (pathPtr: bigint, pathLen: number, name: number, valuePtr: bigint): number => { + return this.hostPathconf(Number(pathPtr), pathLen, name, Number(valuePtr)); + }, + host_fpathconf: (handle: bigint, name: number, valuePtr: bigint): number => { + return this.hostFpathconf(handle, name, Number(valuePtr)); + }, host_mkdir: (pathPtr: bigint, pathLen: number, mode: number): number => { return this.hostMkdir(Number(pathPtr), pathLen, mode); }, @@ -1412,6 +1418,44 @@ export class WasmPosixKernel { } } + private hostPathconf( + pathPtr: number, + pathLen: number, + name: number, + valuePtr: number, + ): number { + try { + const path = this.readPathFromMemory(pathPtr, pathLen); + const value = this.io.pathconf(path, name); + this.getMemoryDataView().setBigInt64( + valuePtr, + BigInt(value ?? -1), + true, + ); + return 0; + } catch (e) { + return negErrno(e); + } + } + + private hostFpathconf( + handle: bigint, + name: number, + valuePtr: number, + ): number { + try { + const value = this.io.fpathconf(Number(handle), name); + this.getMemoryDataView().setBigInt64( + valuePtr, + BigInt(value ?? -1), + true, + ); + return 0; + } catch (e) { + return negErrno(e); + } + } + /** * host_mkdir(path_ptr, path_len, mode) -> i32 */ diff --git a/host/src/pathconf.ts b/host/src/pathconf.ts new file mode 100644 index 000000000..8401568e5 --- /dev/null +++ b/host/src/pathconf.ts @@ -0,0 +1,84 @@ +import { PATHCONF_NAMES } from "./generated/abi"; +import type { PathconfValue, StatResult } from "./types"; + +export interface PathconfProfile { + supportsSymlinks: boolean; + timestampResolutionNs: number | null; +} + +function invalidAssociation(name: number): never { + const error = new Error( + `EINVAL: pathconf name ${name} is not associated with this object`, + ) as Error & { code: string }; + error.code = "EINVAL"; + throw error; +} + +/** + * Answer filesystem-backed pathconf names after the owning backend has + * validated the path or live handle. Kernel-owned pipes, sockets, and PTYs + * are handled in Rust instead. + */ +export function filesystemPathconf( + stat: StatResult, + name: number, + profile: PathconfProfile, +): PathconfValue { + switch (name) { + case PATHCONF_NAMES.LINK_MAX: + return null; // no backend currently enforces an authoritative maximum + case PATHCONF_NAMES.NAME_MAX: + return 255; // enforced in bytes by the common namespace resolver + case PATHCONF_NAMES.PATH_MAX: + return 4096; // enforced in bytes by the common namespace resolver + case PATHCONF_NAMES.CHOWN_RESTRICTED: + // The kernel enforces chown authorization before every backend call, + // including backends without persistent ownership metadata. + return 1; + case PATHCONF_NAMES.NO_TRUNC: + return 1; // the common resolver rejects overlong byte components + case PATHCONF_NAMES.ASYNC_IO: + // musl implements AIO with guest pthreads over pread/pwrite/fsync. + return (stat.mode & 0o170000) === 0o100000 + ? 1 + : invalidAssociation(name); + case PATHCONF_NAMES.SYNC_IO: + case PATHCONF_NAMES.PRIO_IO: + case PATHCONF_NAMES.FILESIZEBITS: + case PATHCONF_NAMES.REC_INCR_XFER_SIZE: + case PATHCONF_NAMES.REC_MAX_XFER_SIZE: + case PATHCONF_NAMES.REC_MIN_XFER_SIZE: + case PATHCONF_NAMES.REC_XFER_ALIGN: + case PATHCONF_NAMES.ALLOC_SIZE_MIN: + case PATHCONF_NAMES.SYMLINK_MAX: + case PATHCONF_NAMES.FALLOC: + return null; + case PATHCONF_NAMES.POSIX2_SYMLINKS: + return profile.supportsSymlinks ? 1 : null; + case PATHCONF_NAMES.TEXTDOMAIN_MAX: + return 255; + case PATHCONF_NAMES.TIMESTAMP_RESOLUTION: + return profile.timestampResolutionNs; + case PATHCONF_NAMES.PIPE_BUF: { + const fileType = stat.mode & 0o170000; + // Named FIFO support and host atomicity are not uniform yet. Preserve + // the valid association without fabricating a numeric guarantee. For a + // directory the value applies to FIFOs created within that directory. + return fileType === 0o010000 || fileType === 0o040000 + ? null + : invalidAssociation(name); + } + case PATHCONF_NAMES.MAX_CANON: + case PATHCONF_NAMES.MAX_INPUT: + case PATHCONF_NAMES.VDISABLE: + case PATHCONF_NAMES.SOCK_MAXBUF: + return invalidAssociation(name); + default: { + const error = new Error(`EINVAL: invalid pathconf name ${name}`) as Error & { + code: string; + }; + error.code = "EINVAL"; + throw error; + } + } +} diff --git a/host/src/platform/node.ts b/host/src/platform/node.ts index 136ebd26c..54abe1a7d 100644 --- a/host/src/platform/node.ts +++ b/host/src/platform/node.ts @@ -9,7 +9,8 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import type { PlatformIO, StatResult, StatfsResult } from "../types"; +import type { PathconfValue, PlatformIO, StatResult, StatfsResult } from "../types"; +import { filesystemPathconf } from "../pathconf"; import { nativeStatfs, translateOpenFlags } from "../vfs/host-fs"; import { NativeMetadataOverlay } from "./native-metadata"; @@ -166,6 +167,20 @@ export class NodePlatformIO implements PlatformIO { return this.metadata.toStatResult(fs.fstatSync(handle)); } + fpathconf(handle: number, name: number): PathconfValue { + // Validate the live descriptor rather than re-resolving its original + // pathname. This keeps fpathconf valid after rename or unlink. + const stat = this.fstat(handle); + return filesystemPathconf( + stat, + name, + { + supportsSymlinks: true, + timestampResolutionNs: 1_000_000, + }, + ); + } + fileIdentity(_path: string, dev: bigint, ino: bigint): string | null { // Native inode numbers are filesystem-scoped and therefore preserve // aliases reached through separate hard-link paths. An absent inode is @@ -187,6 +202,19 @@ export class NodePlatformIO implements PlatformIO { return nativeStatfs(this.rewritePath(path)); } + pathconf(path: string, name: number): PathconfValue { + const nativePath = this.rewritePath(path); + const stat = this.metadata.toStatResult(fs.statSync(nativePath)); + return filesystemPathconf( + stat, + name, + { + supportsSymlinks: true, + timestampResolutionNs: 1_000_000, + }, + ); + } + mkdir(path: string, mode: number): void { const nativePath = this.rewritePath(path); fs.mkdirSync(nativePath, { mode }); diff --git a/host/src/types.ts b/host/src/types.ts index 49bec173e..3fde5db64 100644 --- a/host/src/types.ts +++ b/host/src/types.ts @@ -40,6 +40,9 @@ export interface StatfsResult { flags: number; } +/** `null` represents a successful indeterminate/unsupported-option result. */ +export type PathconfValue = number | null; + export interface PlatformIO { open(path: string, flags: number, mode: number): number; close(handle: number): number; @@ -57,6 +60,7 @@ export interface PlatformIO { ): number; seek(handle: number, offset: number, whence: number): number; fstat(handle: number): StatResult; + fpathconf(handle: number, name: number): PathconfValue; /** * Qualify a filesystem-reported inode within this PlatformIO instance. @@ -73,6 +77,7 @@ export interface PlatformIO { stat(path: string): StatResult; lstat(path: string): StatResult; statfs(path: string): StatfsResult; + pathconf(path: string, name: number): PathconfValue; mkdir(path: string, mode: number): void; rmdir(path: string): void; unlink(path: string): void; diff --git a/host/src/vfs/device-fs.ts b/host/src/vfs/device-fs.ts index 7e33b1fb7..b5d10500f 100644 --- a/host/src/vfs/device-fs.ts +++ b/host/src/vfs/device-fs.ts @@ -1,4 +1,5 @@ -import type { StatResult, StatfsResult } from "../types"; +import type { PathconfValue, StatResult, StatfsResult } from "../types"; +import { filesystemPathconf } from "../pathconf"; import type { FileSystemBackend, DirEntry } from "./types"; import { DEVFS_SUPER_MAGIC, zeroCapacityStatfs } from "../statfs"; @@ -156,6 +157,14 @@ export class DeviceFileSystem implements FileSystemBackend { }; } + fpathconf(handle: number, name: number): PathconfValue { + const stat = this.fstat(handle); + return filesystemPathconf(stat, name, { + supportsSymlinks: false, + timestampResolutionNs: null, + }); + } + ftruncate(_handle: number, _length: number): void {} fsync(_handle: number): void {} fchmod(_handle: number, _mode: number): void {} @@ -198,6 +207,14 @@ export class DeviceFileSystem implements FileSystemBackend { return stats; } + pathconf(path: string, name: number): PathconfValue { + const stat = this.stat(path); + return filesystemPathconf(stat, name, { + supportsSymlinks: false, + timestampResolutionNs: null, + }); + } + mkdir(_path: string, _mode: number): void { throw new Error("EACCES"); } diff --git a/host/src/vfs/host-fs.ts b/host/src/vfs/host-fs.ts index fc1f138e5..a75b682c0 100644 --- a/host/src/vfs/host-fs.ts +++ b/host/src/vfs/host-fs.ts @@ -7,8 +7,9 @@ import * as fs from "node:fs"; import * as nodePath from "node:path"; -import type { StatResult, StatfsResult } from "../types"; +import type { PathconfValue, StatResult, StatfsResult } from "../types"; import { NativeMetadataOverlay } from "../platform/native-metadata"; +import { filesystemPathconf } from "../pathconf"; import type { FileSystemBackend, DirEntry } from "./types"; import { DEFAULT_STATFS_BLOCK_SIZE, DEFAULT_STATFS_NAMELEN } from "../statfs"; @@ -344,6 +345,21 @@ export class HostFileSystem implements FileSystemBackend { return this.toStatResult(fs.fstatSync(handle)); } + fpathconf(handle: number, name: number): PathconfValue { + // Validate the live descriptor. The remaining values are Kandelo + // namespace/backend capabilities and do not depend on a remembered path, + // so this remains valid after the opened file is renamed or unlinked. + const stat = this.fstat(handle); + return filesystemPathconf( + stat, + name, + { + supportsSymlinks: true, + timestampResolutionNs: 1_000_000, + }, + ); + } + ftruncate(handle: number, length: number): void { fs.ftruncateSync(handle, length); this.metadata.noteNativeContentChange(fs.fstatSync(handle)); @@ -375,6 +391,19 @@ export class HostFileSystem implements FileSystemBackend { return nativeStatfs(this.safePath(path)); } + pathconf(path: string, name: number): PathconfValue { + const nativePath = this.safePath(path); + const stat = this.toStatResult(fs.statSync(nativePath)); + return filesystemPathconf( + stat, + name, + { + supportsSymlinks: true, + timestampResolutionNs: 1_000_000, + }, + ); + } + mkdir(path: string, mode: number): void { const nativePath = this.safePath(path, false); fs.mkdirSync(nativePath, { mode }); diff --git a/host/src/vfs/index.ts b/host/src/vfs/index.ts index bafcebcca..a1aaaffba 100644 --- a/host/src/vfs/index.ts +++ b/host/src/vfs/index.ts @@ -16,6 +16,10 @@ export { OpfsFileSystem } from "./opfs"; export { OpfsChannel, OpfsChannelStatus, OpfsOpcode, OPFS_CHANNEL_SIZE } from "./opfs-channel"; export { NodeTimeProvider, BrowserTimeProvider } from "./time"; export type { FileSystemBackend, TimeProvider, MountConfig, DirEntry } from "./types"; +export { PATHCONF_NAMES } from "../generated/abi"; +export { filesystemPathconf } from "../pathconf"; +export type { PathconfProfile } from "../pathconf"; +export type { PathconfValue } from "../types"; export { DEFAULT_MOUNT_SPEC, ensureMountParentDirectories, diff --git a/host/src/vfs/memory-fs.ts b/host/src/vfs/memory-fs.ts index aaa5239fe..3bcef2610 100644 --- a/host/src/vfs/memory-fs.ts +++ b/host/src/vfs/memory-fs.ts @@ -1,5 +1,6 @@ import { decompress as zstdDecompress } from "fzstd"; -import type { StatResult, StatfsResult } from "../types"; +import type { PathconfValue, StatResult, StatfsResult } from "../types"; +import { filesystemPathconf } from "../pathconf"; import { SFFS_SUPER_MAGIC } from "../statfs"; import type { FileSystemBackend, DirEntry } from "./types"; import { @@ -1568,6 +1569,14 @@ export class MemoryFileSystem implements FileSystemBackend { return this.adaptStatWithLazySize(this.fs.fstat(handle)); } + fpathconf(handle: number, name: number): PathconfValue { + const stat = this.fstat(handle); + return filesystemPathconf(stat, name, { + supportsSymlinks: true, + timestampResolutionNs: 1_000_000, + }); + } + ftruncate(handle: number, length: number): void { this.fs.ftruncate(handle, length); this.invalidateLazyData(this.fs.fstat(handle)); @@ -1609,6 +1618,14 @@ export class MemoryFileSystem implements FileSystemBackend { }; } + pathconf(path: string, name: number): PathconfValue { + const stat = this.stat(path); + return filesystemPathconf(stat, name, { + supportsSymlinks: true, + timestampResolutionNs: 1_000_000, + }); + } + mkdir(path: string, mode: number): void { this.fs.mkdir(path, mode); } diff --git a/host/src/vfs/opfs-worker.ts b/host/src/vfs/opfs-worker.ts index 0ec107ab0..67d98c963 100644 --- a/host/src/vfs/opfs-worker.ts +++ b/host/src/vfs/opfs-worker.ts @@ -644,9 +644,11 @@ async function handleStat(isLstat: boolean): Promise { // Try file const fileHandle = await dir.getFileHandle(name); - const syncHandle = await fileHandle.createSyncAccessHandle(); - const size = syncHandle.getSize(); - syncHandle.close(); + // A file may already have a live synchronous access handle. OPFS permits + // only one such handle per file, so stat must use the snapshot API rather + // than attempting to acquire a second handle just to read the size. + const file = await fileHandle.getFile(); + const size = file.size; const now = Date.now(); channel.writeStatResult({ diff --git a/host/src/vfs/opfs.ts b/host/src/vfs/opfs.ts index 4f13a816d..32030f86a 100644 --- a/host/src/vfs/opfs.ts +++ b/host/src/vfs/opfs.ts @@ -5,7 +5,8 @@ * then block with Atomics.wait() until the OpfsProxyWorker completes * the async OPFS operation. */ -import type { StatResult, StatfsResult } from "../types"; +import type { PathconfValue, StatResult, StatfsResult } from "../types"; +import { filesystemPathconf } from "../pathconf"; import type { FileSystemBackend, DirEntry } from "./types"; import { OpfsChannel, OpfsChannelStatus, OpfsOpcode } from "./opfs-channel"; @@ -130,6 +131,14 @@ export class OpfsFileSystem implements FileSystemBackend { return this.channel.readStatResult(); } + fpathconf(handle: number, name: number): PathconfValue { + const stat = this.fstat(handle); + return filesystemPathconf(stat, name, { + supportsSymlinks: false, + timestampResolutionNs: null, + }); + } + ftruncate(handle: number, length: number): void { this.channel.setArg(0, handle); this.setI64Arg(1, length); @@ -173,6 +182,14 @@ export class OpfsFileSystem implements FileSystemBackend { return this.channel.readStatfsResult(); } + pathconf(path: string, name: number): PathconfValue { + const stat = this.stat(path); + return filesystemPathconf(stat, name, { + supportsSymlinks: false, + timestampResolutionNs: null, + }); + } + mkdir(path: string, mode: number): void { this.channel.setArg(0, mode); const pathLen = this.channel.writeString(path); diff --git a/host/src/vfs/types.ts b/host/src/vfs/types.ts index f77f3a6f0..3867f2bb9 100644 --- a/host/src/vfs/types.ts +++ b/host/src/vfs/types.ts @@ -1,4 +1,4 @@ -import type { StatResult, StatfsResult } from "../types"; +import type { PathconfValue, StatResult, StatfsResult } from "../types"; export interface DirEntry { name: string; @@ -14,6 +14,7 @@ export interface FileSystemBackend { write(handle: number, buffer: Uint8Array, offset: number | null, length: number): number; seek(handle: number, offset: number, whence: number): number; fstat(handle: number): StatResult; + fpathconf(handle: number, name: number): PathconfValue; ftruncate(handle: number, length: number): void; fsync(handle: number): void; fchmod(handle: number, mode: number): void; @@ -23,6 +24,7 @@ export interface FileSystemBackend { stat(path: string): StatResult; lstat(path: string): StatResult; statfs(path: string): StatfsResult; + pathconf(path: string, name: number): PathconfValue; mkdir(path: string, mode: number): void; rmdir(path: string): void; unlink(path: string): void; diff --git a/host/src/vfs/vfs.ts b/host/src/vfs/vfs.ts index 886038753..9d7adaeec 100644 --- a/host/src/vfs/vfs.ts +++ b/host/src/vfs/vfs.ts @@ -1,4 +1,10 @@ -import type { NetworkIO, PlatformIO, StatResult, StatfsResult } from "../types"; +import type { + NetworkIO, + PathconfValue, + PlatformIO, + StatResult, + StatfsResult, +} from "../types"; import type { FileSystemBackend, MountConfig, TimeProvider } from "./types"; interface MountEntry { @@ -158,6 +164,11 @@ export class VirtualPlatformIO implements PlatformIO { return info.backend.fstat(info.localHandle); } + fpathconf(handle: number, name: number): PathconfValue { + const info = this.getFileHandle(handle); + return info.backend.fpathconf(info.localHandle, name); + } + ftruncate(handle: number, length: number): void { const info = this.getFileHandle(handle); info.backend.ftruncate(info.localHandle, length); @@ -195,6 +206,11 @@ export class VirtualPlatformIO implements PlatformIO { return backend.statfs(relativePath); } + pathconf(path: string, name: number): PathconfValue { + const { backend, relativePath } = this.resolve(path); + return backend.pathconf(relativePath, name); + } + mkdir(path: string, mode: number): void { const { backend, relativePath } = this.resolve(path); backend.mkdir(relativePath, mode); diff --git a/host/test/global-setup.ts b/host/test/global-setup.ts index 63e0e7933..0023d5512 100644 --- a/host/test/global-setup.ts +++ b/host/test/global-setup.ts @@ -27,6 +27,7 @@ const TEST_PROGRAMS = [ "lseek_invalid_test.c", "environment_lifecycle_test.c", "chown_sentinel_test.c", + "pathconf_test.c", "rlimit_fsize_test.c", "unix_listener_exec_test.c", "putenv_test.c", @@ -56,7 +57,11 @@ const FORK_INSTRUMENTED_PROGRAMS = new Set([ ]); /** Operation-boundary regressions that must also run through a memory64 guest. */ -const WASM64_TEST_PROGRAMS = ["chown_sentinel_test.c", "rlimit_fsize_test.c"]; +const WASM64_TEST_PROGRAMS = [ + "chown_sentinel_test.c", + "pathconf_test.c", + "rlimit_fsize_test.c", +]; /** WAT fixtures used by host runtime tests. */ const WAT_FIXTURES = [ diff --git a/host/test/pathconf.test.ts b/host/test/pathconf.test.ts new file mode 100644 index 000000000..238aef6e3 --- /dev/null +++ b/host/test/pathconf.test.ts @@ -0,0 +1,231 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { PATHCONF_NAMES } from "../src/generated/abi"; +import { filesystemPathconf } from "../src/pathconf"; +import { DeviceFileSystem } from "../src/vfs/device-fs"; +import { HostFileSystem } from "../src/vfs/host-fs"; +import { MemoryFileSystem } from "../src/vfs/memory-fs"; +import { NodeTimeProvider } from "../src/vfs/time"; +import { VirtualPlatformIO } from "../src/vfs/vfs"; +import { + ENOENT, + O_CREAT, + O_RDONLY, + O_RDWR, + SFSError, +} from "../src/vfs/sharedfs-vendor"; +import type { StatResult } from "../src/types"; +import { runCentralizedProgram } from "./centralized-test-helper"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../.."); + +function memoryFileSystem(): MemoryFileSystem { + return MemoryFileSystem.create(new SharedArrayBuffer(2 * 1024 * 1024)); +} + +describe("pathconf capability values", () => { + const regularStat: StatResult = { + dev: 1, + ino: 1, + mode: 0o100644, + nlink: 1, + uid: 0, + gid: 0, + size: 0, + atimeMs: 0, + mtimeMs: 0, + ctimeMs: 0, + }; + const fifoStat = { ...regularStat, mode: 0o010644 }; + const directoryStat = { ...regularStat, mode: 0o040755 }; + const memoryProfile = { + supportsSymlinks: true, + timestampResolutionNs: 1_000_000, + }; + const opfsProfile = { + supportsSymlinks: false, + timestampResolutionNs: null, + }; + + it("keeps the generated name table complete and unique", () => { + expect(Object.keys(PATHCONF_NAMES)).toHaveLength(24); + expect(new Set(Object.values(PATHCONF_NAMES)).size).toBe(24); + expect(Math.min(...Object.values(PATHCONF_NAMES))).toBe(0); + expect(Math.max(...Object.values(PATHCONF_NAMES))).toBe(23); + }); + + it("reports enforced namespace limits and backend capabilities", () => { + expect( + filesystemPathconf(regularStat, PATHCONF_NAMES.NAME_MAX, memoryProfile), + ).toBe(255); + expect( + filesystemPathconf(regularStat, PATHCONF_NAMES.PATH_MAX, memoryProfile), + ).toBe(4096); + expect( + filesystemPathconf(regularStat, PATHCONF_NAMES.NO_TRUNC, memoryProfile), + ).toBe(1); + expect( + filesystemPathconf( + regularStat, + PATHCONF_NAMES.CHOWN_RESTRICTED, + opfsProfile, + ), + ).toBe(1); + expect( + filesystemPathconf( + regularStat, + PATHCONF_NAMES.POSIX2_SYMLINKS, + memoryProfile, + ), + ).toBe(1); + expect( + filesystemPathconf( + regularStat, + PATHCONF_NAMES.POSIX2_SYMLINKS, + opfsProfile, + ), + ).toBeNull(); + expect( + filesystemPathconf(regularStat, PATHCONF_NAMES.ASYNC_IO, memoryProfile), + ).toBe(1); + expect( + filesystemPathconf( + regularStat, + PATHCONF_NAMES.TIMESTAMP_RESOLUTION, + memoryProfile, + ), + ).toBe(1_000_000); + expect( + filesystemPathconf( + regularStat, + PATHCONF_NAMES.TIMESTAMP_RESOLUTION, + opfsProfile, + ), + ).toBeNull(); + }); + + it("distinguishes indeterminate values from invalid associations", () => { + expect( + filesystemPathconf(regularStat, PATHCONF_NAMES.LINK_MAX, memoryProfile), + ).toBeNull(); + expect( + filesystemPathconf( + regularStat, + PATHCONF_NAMES.FILESIZEBITS, + memoryProfile, + ), + ).toBeNull(); + expect(() => + filesystemPathconf(regularStat, PATHCONF_NAMES.PIPE_BUF, memoryProfile), + ).toThrow(/EINVAL/); + expect( + filesystemPathconf(fifoStat, PATHCONF_NAMES.PIPE_BUF, memoryProfile), + ).toBeNull(); + expect( + filesystemPathconf( + directoryStat, + PATHCONF_NAMES.PIPE_BUF, + memoryProfile, + ), + ).toBeNull(); + expect(() => filesystemPathconf(regularStat, 999, memoryProfile)).toThrow( + /EINVAL/, + ); + }); +}); + +describe("pathconf VFS routing", () => { + it("uses the longest-prefix mount for pathname queries", () => { + const root = memoryFileSystem(); + const mounted = memoryFileSystem(); + const rootQuery = vi.spyOn(root, "pathconf").mockReturnValue(111); + const mountedQuery = vi.spyOn(mounted, "pathconf").mockReturnValue(222); + const io = new VirtualPlatformIO( + [ + { mountPoint: "/mnt", backend: mounted }, + { mountPoint: "/", backend: root }, + ], + new NodeTimeProvider(), + ); + + expect(io.pathconf("/mnt/file", PATHCONF_NAMES.PATH_MAX)).toBe(222); + expect(mountedQuery).toHaveBeenCalledWith("/file", PATHCONF_NAMES.PATH_MAX); + expect(rootQuery).not.toHaveBeenCalled(); + }); + + it("keeps fpathconf on the open handle's backend after unlink", () => { + const root = memoryFileSystem(); + const mounted = memoryFileSystem(); + root.mkdir("/mnt", 0o755); + const io = new VirtualPlatformIO( + [ + { mountPoint: "/mnt", backend: mounted }, + { mountPoint: "/", backend: root }, + ], + new NodeTimeProvider(), + ); + const fd = io.open("/mnt/file", O_CREAT | O_RDWR, 0o644); + io.unlink("/mnt/file"); + + expect(io.fpathconf(fd, PATHCONF_NAMES.NAME_MAX)).toBe(255); + try { + io.pathconf("/mnt/file", PATHCONF_NAMES.NAME_MAX); + throw new Error("pathconf unexpectedly accepted an unlinked path"); + } catch (error) { + expect(error).toBeInstanceOf(SFSError); + expect((error as SFSError).code).toBe(ENOENT); + } + io.close(fd); + }); + + it("validates device paths and live device handles", () => { + const device = new DeviceFileSystem(); + expect(device.pathconf("/null", PATHCONF_NAMES.NAME_MAX)).toBe(255); + const fd = device.open("/null", O_RDONLY, 0); + expect(device.fpathconf(fd, PATHCONF_NAMES.CHOWN_RESTRICTED)).toBe(1); + device.close(fd); + expect(() => device.fpathconf(fd, PATHCONF_NAMES.NAME_MAX)).toThrow(/EBADF/); + }); +}); + +describe("HostFileSystem fpathconf", () => { + const roots: string[] = []; + + afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); + }); + + it("uses the live descriptor after its pathname is unlinked", () => { + const root = mkdtempSync(join(tmpdir(), "kandelo-pathconf-")); + roots.push(root); + writeFileSync(join(root, "file"), "data"); + const fs = new HostFileSystem(root); + const fd = fs.open("/file", O_RDONLY, 0); + fs.unlink("/file"); + + expect(fs.fpathconf(fd, PATHCONF_NAMES.PATH_MAX)).toBe(4096); + expect(() => fs.pathconf("/file", PATHCONF_NAMES.PATH_MAX)).toThrow(/ENOENT/); + fs.close(fd); + }); +}); + +describe("pathconf guest ABI", () => { + it.each([".wasm", ".wasm64.wasm"])( + "preserves values, errno, and pointer safety (%s)", + async (suffix) => { + const result = await runCentralizedProgram({ + programPath: join(repoRoot, `examples/pathconf_test${suffix}`), + argv: ["pathconf-test"], + timeout: 15_000, + useDefaultRootfs: false, + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("PATHCONF_PASS"); + expect(result.stderr).toBe(""); + }, + ); +}); diff --git a/host/test/vfs.test.ts b/host/test/vfs.test.ts index c36664890..174c7a55a 100644 --- a/host/test/vfs.test.ts +++ b/host/test/vfs.test.ts @@ -84,6 +84,10 @@ function createMockBackend(): FileSystemBackend & { calls: string[] } { calls.push(`fstat:${h}`); return { ...dummyStat }; }, + fpathconf: (h, name) => { + calls.push(`fpathconf:${h}:${name}`); + return 4096; + }, ftruncate: (h, l) => { calls.push(`ftruncate:${h}`); }, @@ -108,6 +112,10 @@ function createMockBackend(): FileSystemBackend & { calls: string[] } { calls.push(`statfs:${p}`); return { ...dummyStatfs }; }, + pathconf: (p, name) => { + calls.push(`pathconf:${p}:${name}`); + return 4096; + }, mkdir: (p, m) => { calls.push(`mkdir:${p}`); }, diff --git a/libc/glue/syscall_glue.c b/libc/glue/syscall_glue.c index 2f6b9db48..c78008193 100644 --- a/libc/glue/syscall_glue.c +++ b/libc/glue/syscall_glue.c @@ -996,16 +996,18 @@ static long __do_syscall(long n, long a1, long a2, long a3, case SYS_SYSCONF: return (long)kernel_sysconf((int32_t)a1); - /* pathconf — (path, name) */ + /* pathconf — (path, name, int64_t *value) */ case SYS_PATHCONF: { const char *p = (const char *)(uintptr_t)a1; return (long)kernel_pathconf((const uint8_t *)p, slen(p), - (int32_t)a2); + (int32_t)a2, + (int64_t *)(uintptr_t)a3); } - /* fpathconf — (fd, name) */ + /* fpathconf — (fd, name, int64_t *value) */ case SYS_FPATHCONF: - return (long)kernel_fpathconf((int32_t)a1, (int32_t)a2); + return (long)kernel_fpathconf((int32_t)a1, (int32_t)a2, + (int64_t *)(uintptr_t)a3); /* realpath — (path, buf, buflen) */ case SYS_REALPATH: { diff --git a/libc/glue/syscall_imports.h b/libc/glue/syscall_imports.h index 78f6b942f..556de55db 100644 --- a/libc/glue/syscall_imports.h +++ b/libc/glue/syscall_imports.h @@ -514,10 +514,11 @@ KERNEL_IMPORT(kernel_sysconf) int64_t kernel_sysconf(int32_t name); KERNEL_IMPORT(kernel_pathconf) -int64_t kernel_pathconf(const uint8_t *path_ptr, uint32_t path_len, int32_t name); +int32_t kernel_pathconf(const uint8_t *path_ptr, uint32_t path_len, int32_t name, + int64_t *value_ptr); KERNEL_IMPORT(kernel_fpathconf) -int64_t kernel_fpathconf(int32_t fd, int32_t name); +int32_t kernel_fpathconf(int32_t fd, int32_t name, int64_t *value_ptr); KERNEL_IMPORT(kernel_realpath) int32_t kernel_realpath(const uint8_t *path_ptr, uint32_t path_len, diff --git a/libc/musl-overlay/include/limits.h b/libc/musl-overlay/include/limits.h index 486fd02f0..80bb13212 100644 --- a/libc/musl-overlay/include/limits.h +++ b/libc/musl-overlay/include/limits.h @@ -40,7 +40,6 @@ #include #define PIPE_BUF 4096 -#define FILESIZEBITS 64 #ifndef NAME_MAX #define NAME_MAX 255 #endif diff --git a/libc/musl-overlay/include/unistd.h b/libc/musl-overlay/include/unistd.h index c95dd594f..099cade06 100644 --- a/libc/musl-overlay/include/unistd.h +++ b/libc/musl-overlay/include/unistd.h @@ -523,9 +523,9 @@ pid_t gettid(void); #define _XOPEN_UUCP (-1) /* Feature test macros — supported or partially supported features */ -#define _POSIX_PRIORITIZED_IO _POSIX_VERSION +#define _POSIX_PRIORITIZED_IO (-1) #define _POSIX_PRIORITY_SCHEDULING _POSIX_VERSION -#define _POSIX_SYNCHRONIZED_IO _POSIX_VERSION +#define _POSIX_SYNCHRONIZED_IO (-1) #define _POSIX2_C_DEV (-1) #define _POSIX2_SW_DEV (-1) #define _XOPEN_CRYPT 1 diff --git a/libc/musl-overlay/src/conf/wasm32posix/fpathconf.c b/libc/musl-overlay/src/conf/wasm32posix/fpathconf.c new file mode 100644 index 000000000..d4768b7f9 --- /dev/null +++ b/libc/musl-overlay/src/conf/wasm32posix/fpathconf.c @@ -0,0 +1,11 @@ +#include +#include +#include "syscall.h" + +long fpathconf(int fd, int name) +{ + int64_t value = -1; + long rc = __syscall(SYS_fpathconf, fd, name, &value); + if (rc < 0) return __syscall_ret(rc); + return (long)value; +} diff --git a/libc/musl-overlay/src/conf/wasm32posix/pathconf.c b/libc/musl-overlay/src/conf/wasm32posix/pathconf.c new file mode 100644 index 000000000..bb4ee1e95 --- /dev/null +++ b/libc/musl-overlay/src/conf/wasm32posix/pathconf.c @@ -0,0 +1,11 @@ +#include +#include +#include "syscall.h" + +long pathconf(const char *path, int name) +{ + int64_t value = -1; + long rc = __syscall(SYS_pathconf, path, name, &value); + if (rc < 0) return __syscall_ret(rc); + return (long)value; +} diff --git a/tools/xtask/src/dump_abi.rs b/tools/xtask/src/dump_abi.rs index 5712b668f..51100a1ca 100644 --- a/tools/xtask/src/dump_abi.rs +++ b/tools/xtask/src/dump_abi.rs @@ -461,6 +461,12 @@ fn render_ts_module() -> String { } out.push_str("} as const;\n\n"); + out.push_str("export const PATHCONF_NAMES = {\n"); + for (name, number) in shared::pathconf::ABI_NAMES { + out.push_str(&format!(" {name}: {number},\n")); + } + out.push_str("} as const;\n\n"); + out.push_str("export const ABI_SYSCALL_NAMES: Record = {\n"); for (number, name) in all_syscall_log_names() { out.push_str(&format!(" {number}: {name:?},\n")); @@ -478,6 +484,7 @@ fn render_ts_module() -> String { out.push_str(" direction: SyscallArgDirection;\n"); out.push_str(" size: SyscallArgSizeSpec;\n"); out.push_str(" nullable?: boolean;\n"); + out.push_str(" required?: boolean;\n"); out.push_str(" copyRetvalAdd?: number;\n"); out.push_str("}\n\n"); @@ -504,6 +511,9 @@ fn ts_syscall_arg_desc(desc: &shared::host_abi::SyscallArgDesc) -> String { if desc.nullable { s.push_str(", nullable: true"); } + if desc.required { + s.push_str(", required: true"); + } if desc.copy_retval_add != 0 { s.push_str(&format!(", copyRetvalAdd: {}", desc.copy_retval_add)); } @@ -667,6 +677,7 @@ fn build_snapshot(kernel_wasm: &std::path::Path) -> Result { root.insert("marshalled_structs".into(), marshalled_structs()); root.insert("syscalls".into(), syscalls()); + root.insert("pathconf_names".into(), pathconf_names()); root.insert( "host_intercepted_syscalls".into(), host_intercepted_syscalls(), @@ -1285,6 +1296,14 @@ fn syscalls() -> Value { Value::Array(list) } +fn pathconf_names() -> Value { + let mut names: JsonMap = BTreeMap::new(); + for (name, number) in shared::pathconf::ABI_NAMES { + names.insert((*name).into(), json!(number)); + } + Value::Object(names.into_iter().collect()) +} + #[derive(Debug, Clone, Copy)] struct HostInterceptedSyscall { constant_name: &'static str, @@ -1518,6 +1537,9 @@ fn syscall_arg_desc_json(desc: &shared::host_abi::SyscallArgDesc) -> Value { if desc.nullable { m.insert("nullable".into(), json!(true)); } + if desc.required { + m.insert("required".into(), json!(true)); + } if desc.copy_retval_add != 0 { m.insert("copyRetvalAdd".into(), json!(desc.copy_retval_add)); } @@ -2085,6 +2107,22 @@ mod tests { ); } + #[test] + fn generated_typescript_contains_pathconf_names_and_required_outputs() { + let rendered = render_ts_module(); + assert!(rendered.contains("export const PATHCONF_NAMES = {")); + assert!(rendered.contains(" PATH_MAX: 4,")); + assert!(rendered.contains(" TIMESTAMP_RESOLUTION: 23,")); + assert!(rendered.contains( + "{ argIndex: 2, direction: \"out\", size: { type: \"fixed\", size: 8 }, required: true }" + )); + + let names = pathconf_names(); + assert_eq!(names["LINK_MAX"], json!(0)); + assert_eq!(names["TIMESTAMP_RESOLUTION"], json!(23)); + assert_eq!(names.as_object().unwrap().len(), 24); + } + fn base_snapshot() -> Value { json!({ "abi_version": 10, @@ -2272,6 +2310,19 @@ mod tests { ); } + #[test] + fn making_existing_syscall_pointer_required_is_breaking() { + let old = base_snapshot(); + let mut new = old.clone(); + new["syscall_arg_descriptors"]["1"][0]["required"] = json!(true); + + let report = classify_compat_change(&old, &new).unwrap(); + assert_eq!( + report.breaking, + vec!["changed syscall_arg_descriptors entry \"1\""] + ); + } + #[test] fn changed_channel_layout_is_breaking() { let old = base_snapshot();