Skip to content

Commit 3152fcf

Browse files
divybotlittledivy
andcommitted
fix(ext/node): address WASI review comments
* path_filestat_set_times now honors LOOKUPFLAGS_SYMLINK_FOLLOW like path_filestat_get does β€” switches between metadata/symlink_metadata for the absent-bit lookup and between set_file_times / set_symlink_file_times for the utimensat-equivalent call. Previously flags=0 silently followed. * dup_user_fd_to_file (Unix) now uses fcntl(F_DUPFD_CLOEXEC, 0) instead of plain dup so the dup'd host fd doesn't leak into processes the WASI program spawns. Windows already creates the duplicate handle with bInheritHandle=false. * path_link consults the source/target dirfd rights via has_right for parity with the other mutating path_* ops; added RIGHTS_PATH_LINK_SOURCE / RIGHTS_PATH_LINK_TARGET constants and RIGHTS_PATH_LINK_TARGET to RIGHTS_MUTATING. * fd_rights doc comment: call out that HostFile entries are intentionally not rights-gated because their permission was paid upstream by node:fs.openSync. * path_symlink reverse-normalization comment: note that the Windows backslash->forward-slash rewrite in path_readlink is best-effort and biased toward the WASI round-trip case. * poll_oneoff thread::sleep: note that it intentionally blocks the V8 isolate for the clock subscription duration. * PollSubscription.clock_precision dead_code reason matches the field's role rather than the errno copy-paste. * WASI finalizeBindings docstring: spell out the one-way #started transition shared with start()/initialize(). * Renamed "constructor emits ExperimentalWarning once" test to reflect what it actually checks β€” the warning carries the WASI message β€” since the once-only behavior can't be verified across the test process. Co-Authored-By: Divy Srivastava <me@littledivy.com>
1 parent 3dad431 commit 3152fcf

3 files changed

Lines changed: 82 additions & 17 deletions

File tree

β€Žext/node/ops/wasi.rsβ€Ž

Lines changed: 72 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ const RIGHTS_FD_WRITE: u64 = 1 << 6;
5656
const RIGHTS_FD_ADVISE: u64 = 1 << 7;
5757
const RIGHTS_FD_ALLOCATE: u64 = 1 << 8;
5858
const RIGHTS_PATH_CREATE_DIRECTORY: u64 = 1 << 9;
59+
const RIGHTS_PATH_LINK_SOURCE: u64 = 1 << 11;
60+
const RIGHTS_PATH_LINK_TARGET: u64 = 1 << 12;
5961
const RIGHTS_PATH_OPEN: u64 = 1 << 13;
6062
const RIGHTS_FD_READDIR: u64 = 1 << 14;
6163
const RIGHTS_PATH_READLINK: u64 = 1 << 15;
@@ -77,6 +79,8 @@ const RIGHTS_DIR: u64 = RIGHTS_FD_READ
7779
| RIGHTS_FD_DATASYNC
7880
| RIGHTS_FD_ADVISE
7981
| RIGHTS_PATH_CREATE_DIRECTORY
82+
| RIGHTS_PATH_LINK_SOURCE
83+
| RIGHTS_PATH_LINK_TARGET
8084
| RIGHTS_PATH_OPEN
8185
| RIGHTS_FD_READDIR
8286
| RIGHTS_PATH_READLINK
@@ -113,6 +117,7 @@ const RIGHTS_MUTATING: u64 = RIGHTS_FD_WRITE
113117
| RIGHTS_FD_FILESTAT_SET_TIMES
114118
| RIGHTS_PATH_CREATE_DIRECTORY
115119
| RIGHTS_PATH_FILESTAT_SET_TIMES
120+
| RIGHTS_PATH_LINK_TARGET
116121
| RIGHTS_PATH_SYMLINK
117122
| RIGHTS_PATH_REMOVE_DIRECTORY
118123
| RIGHTS_PATH_UNLINK_FILE
@@ -196,6 +201,13 @@ impl WasiInner {
196201
/// is a regular file or directory we tracked. Stdio and HostFile entries
197202
/// return None because rights are implied by the entry type rather than
198203
/// stored explicitly.
204+
///
205+
/// Note: callers pair this with [`has_right`], which treats `None` as
206+
/// "all rights granted". For inherited stdio that's fine. For HostFile,
207+
/// the per-op rights gate is intentionally skipped because the Deno
208+
/// permission for the underlying path was already paid when node:fs
209+
/// opened the user fd (see [`make_stdio_entry`]'s `FdTable` check);
210+
/// the OS-level open mode then limits what actually succeeds.
199211
fn fd_rights(&self, fd: i32) -> Option<u64> {
200212
match self.get_fd(fd) {
201213
Some(FdEntry::File { rights, .. })
@@ -1685,10 +1697,14 @@ impl WasiContext {
16851697
match std::fs::read_link(&resolved) {
16861698
Ok(target) => {
16871699
let target_bytes = target.as_os_str().as_encoded_bytes();
1688-
// On Windows, path_symlink rewrote forward slashes to backslashes so
1689-
// the OS could resolve relative symlinks. Convert them back here so
1690-
// WASI callers see the POSIX-style target they originally stored
1691-
// (Node's test-wasi-symlinks asserts the bytes round-trip exactly).
1700+
// Best-effort reverse of the normalization in path_symlink: on
1701+
// Windows we rewrote forward slashes to backslashes so the OS
1702+
// could resolve relative symlinks, so here we flip them back to
1703+
// give WASI callers a POSIX-style target. This is biased toward
1704+
// the test-wasi-symlinks round-trip case β€” a symlink created
1705+
// outside WASI with literal backslashes in its target will have
1706+
// its bytes rewritten to forward slashes too, which is a known
1707+
// limitation we accept to keep the WASI surface POSIX-shaped.
16921708
#[cfg(windows)]
16931709
let owned = target_bytes
16941710
.iter()
@@ -1780,7 +1796,7 @@ impl WasiContext {
17801796
fn path_filestat_set_times(
17811797
&self,
17821798
#[smi] dirfd: i32,
1783-
#[smi] _flags: i32,
1799+
#[smi] flags: i32,
17841800
#[smi] path_ptr: i32,
17851801
#[smi] path_len: i32,
17861802
#[number] atim: i64,
@@ -1791,17 +1807,28 @@ impl WasiContext {
17911807
let Some(path_str) = read_string(memory, path_ptr, path_len) else {
17921808
return ERRNO_FAULT;
17931809
};
1810+
let follow_symlinks = (flags as u32) & LOOKUPFLAGS_SYMLINK_FOLLOW != 0;
17941811
let inner = self.inner.borrow();
1795-
let resolved = match inner.resolve_preopen_path(
1812+
let resolved = match inner.resolve_preopen_path_ex(
17961813
dirfd,
17971814
&path_str,
17981815
&self.permissions,
17991816
OpenAccessKind::Write,
1817+
follow_symlinks,
18001818
) {
18011819
Ok(p) => p,
18021820
Err(e) => return e,
18031821
};
1804-
let meta = match std::fs::metadata(&resolved) {
1822+
// Mirror path_filestat_get: honor LOOKUPFLAGS_SYMLINK_FOLLOW for both the
1823+
// metadata lookup we use to fill in absent atim/mtim and for the
1824+
// utimensat-equivalent call. Otherwise a caller passing flags=0 would
1825+
// silently traverse symlinks and modify the target's times.
1826+
let meta = if follow_symlinks {
1827+
std::fs::metadata(&resolved)
1828+
} else {
1829+
std::fs::symlink_metadata(&resolved)
1830+
};
1831+
let meta = match meta {
18051832
Ok(m) => m,
18061833
Err(e) => return io_err_to_errno(&e),
18071834
};
@@ -1814,7 +1841,12 @@ impl WasiContext {
18141841
Ok(v) => v,
18151842
Err(e) => return e,
18161843
};
1817-
match filetime::set_file_times(&resolved, atime, mtime) {
1844+
let result = if follow_symlinks {
1845+
filetime::set_file_times(&resolved, atime, mtime)
1846+
} else {
1847+
filetime::set_symlink_file_times(&resolved, atime, mtime)
1848+
};
1849+
match result {
18181850
Ok(()) => ERRNO_SUCCESS,
18191851
Err(e) => io_err_to_errno(&e),
18201852
}
@@ -1839,6 +1871,16 @@ impl WasiContext {
18391871
return ERRNO_FAULT;
18401872
};
18411873
let inner = self.inner.borrow();
1874+
// The Deno permission check inside `resolve_preopen_path` is what
1875+
// actually gates the OS-level hardlink; the per-fd rights check here
1876+
// is purely for parity with the other path_* ops that consult the
1877+
// dirfd's stored rights (`path_create_directory`, `path_unlink_file`,
1878+
// etc.). Skipping it would be safe but inconsistent.
1879+
if !has_right(inner.fd_rights(old_dirfd), RIGHTS_PATH_LINK_SOURCE)
1880+
|| !has_right(inner.fd_rights(new_dirfd), RIGHTS_PATH_LINK_TARGET)
1881+
{
1882+
return ERRNO_NOTCAPABLE;
1883+
}
18421884
let old_resolved = match inner.resolve_preopen_path(
18431885
old_dirfd,
18441886
&old_path,
@@ -1983,6 +2025,13 @@ impl WasiContext {
19832025

19842026
// If no FD subscription fired and a clock deadline was set, wait it out.
19852027
// The clock event itself only fires after the wait, signaled below.
2028+
//
2029+
// NOTE: this intentionally blocks the V8 isolate thread for the full
2030+
// clock subscription. WASI's poll_oneoff is a synchronous host call, so
2031+
// we can't yield back to the event loop here without breaking the
2032+
// contract. Callers passing long clock timeouts will hang JS until the
2033+
// deadline elapses β€” that's the inherent cost of running a synchronous
2034+
// WASI program on a single-threaded runtime, not a bug.
19862035
if events.is_empty()
19872036
&& let Some(due) = deadline
19882037
{
@@ -2297,14 +2346,18 @@ fn dup_user_fd_to_file(
22972346
#[cfg(unix)]
22982347
{
22992348
use std::os::fd::FromRawFd;
2300-
// SAFETY: libc::dup is always safe to call. It either returns -1 on
2301-
// error or a fresh fd owned by the caller. We only construct File from
2302-
// a successful dup, so the resulting File owns a valid OS fd.
2303-
let new_fd = unsafe { libc::dup(user_fd) };
2349+
// Use F_DUPFD_CLOEXEC instead of plain dup so the dup'd fd is marked
2350+
// close-on-exec. A WASI program that spawns a child process would
2351+
// otherwise leak this host fd into the child, which is exactly the
2352+
// kind of escape hatch we just closed off in make_stdio_entry.
2353+
// SAFETY: fcntl(F_DUPFD_CLOEXEC, 0) is always safe to call. It either
2354+
// returns -1 on error or a fresh fd (>=0) owned by the caller.
2355+
let new_fd = unsafe { libc::fcntl(user_fd, libc::F_DUPFD_CLOEXEC, 0) };
23042356
if new_fd >= 0 {
2305-
// SAFETY: new_fd was just returned by libc::dup, so it's a fresh OS
2306-
// file descriptor we now own. Wrapping it in std::fs::File transfers
2307-
// ownership to the returned File, which closes the fd on Drop.
2357+
// SAFETY: new_fd was just returned by fcntl(F_DUPFD_CLOEXEC), so it's
2358+
// a fresh OS file descriptor we now own. Wrapping it in std::fs::File
2359+
// transfers ownership to the returned File, which closes the fd on
2360+
// Drop.
23082361
return Some(unsafe { std::fs::File::from_raw_fd(new_fd) });
23092362
}
23102363
let _ = state;
@@ -2356,7 +2409,10 @@ struct PollSubscription {
23562409
tag: u8,
23572410
clock_id: i32,
23582411
clock_timeout: u64,
2359-
#[allow(dead_code, reason = "kept for parity with full WASI errno set")]
2412+
#[allow(
2413+
dead_code,
2414+
reason = "WASI subscription field, not currently consulted"
2415+
)]
23602416
clock_precision: u64,
23612417
clock_flags: u16,
23622418
fd: i32,

β€Žext/node/polyfills/wasi.tsβ€Ž

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -749,6 +749,15 @@ class WASI {
749749
this.#memory = memory as WebAssembly.Memory;
750750
// finalizeBindings is intentionally idempotent: the same WASI instance
751751
// can be bound to multiple wasm instances across threads.
752+
//
753+
// Setting #started here is a one-way transition shared with start()
754+
// and initialize(): once finalizeBindings has been called on this
755+
// instance (typically from the thread-spawn worker path), a later
756+
// start()/initialize() call on the same WASI object will throw
757+
// ERR_WASI_ALREADY_STARTED. Node's wasi_thread_start path has the
758+
// same behavior - re-entering start/initialize on a bound WASI is
759+
// never valid, the spawning thread is expected to construct a fresh
760+
// WASI per wasm module.
752761
this.#started = true;
753762
}
754763

β€Žtests/unit_node/wasi_test.tsβ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ Deno.test("[node/wasi] - finalizeBindings binds wasm memory", () => {
196196
assertEquals(view.getUint32(0, true), 0);
197197
});
198198

199-
Deno.test("[node/wasi] - constructor emits ExperimentalWarning once", async () => {
199+
Deno.test("[node/wasi] - constructor emits ExperimentalWarning with the expected message", async () => {
200200
const warnings: { name?: string; message?: string }[] = [];
201201
const proc = (globalThis as any).process;
202202
const listener = (w: { name?: string; message?: string }) => {

0 commit comments

Comments
Β (0)