diff --git a/README.md b/README.md index dc6de346..369fbcf9 100644 --- a/README.md +++ b/README.md @@ -579,6 +579,14 @@ Deprecated error-handling domains (sync-only). Comprehensive filesystem API built on WASI filesystem. +Symbolic links are persistent WASI filesystem objects, so linked package trees +remain visible to later fresh execution runtimes. Link targets must be relative: +WASI rejects rooted targets, and `symlink` fails with `EINVAL` without creating +an object when given one. Production Golem and macOS/Linux local development +support these links. Windows local development currently requires Developer +Mode or an equivalent symlink privilege; non-link filesystem and npm workflows +are unaffected. + - **Sync:** `readFileSync`, `writeFileSync`, `appendFileSync`, `openSync`, `closeSync`, `readSync`, `writeSync`, `ftruncateSync`, `fsyncSync`, `fdatasyncSync`, `statSync`, `lstatSync`, `fstatSync`, `statfsSync`, `readdirSync`, `accessSync`, `existsSync`, `realpathSync`, `truncateSync`, `copyFileSync`, `linkSync`, `symlinkSync`, `readlinkSync`, `chmodSync`, `fchmodSync`, `lchmodSync`, `chownSync`, `fchownSync`, `lchownSync`, `utimesSync`, `futimesSync`, `lutimesSync`, `unlinkSync`, `renameSync`, `mkdirSync`, `rmdirSync`, `rmSync`, `mkdtempSync`, `opendirSync`, `readvSync`, `writevSync`, `cpSync` - **Async (callback):** `readFile`, `writeFile`, `appendFile`, `open`, `close`, `read`, `write`, `stat`, `lstat`, `fstat`, `statfs`, `ftruncate`, `fsync`, `fdatasync`, `readdir`, `access`, `exists`, `realpath`, `truncate`, `copyFile`, `link`, `symlink`, `readlink`, `chmod`, `fchmod`, `lchmod`, `chown`, `fchown`, `lchown`, `utimes`, `futimes`, `lutimes`, `unlink`, `rename`, `mkdir`, `rmdir`, `rm`, `mkdtemp`, `opendir`, `watch`, `watchFile`, `unwatchFile`, `readv`, `writev`, `cp`, `openAsBlob` - **Streams:** `createReadStream`, `createWriteStream` diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/fs.rs b/crates/wasm-rquickjs/skeleton/src/builtin/fs.rs index e97d1d4f..791925c4 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/fs.rs +++ b/crates/wasm-rquickjs/skeleton/src/builtin/fs.rs @@ -1,4 +1,3 @@ -use std::collections::HashMap; use std::time::{Duration, SystemTime, UNIX_EPOCH}; // The bulk of this module performs filesystem I/O through `std::fs`, which is backed by the @@ -87,6 +86,43 @@ fn wasi_fs_error_to_io(e: &wasi_fs_types::ErrorCode) -> std::io::Error { } } +fn resolve_preopen_relative( + dirs: &[(wasi_fs_types::Descriptor, String)], + path: &str, +) -> Option<(usize, String)> { + let mut best_match = None; + let mut best_prefix_len = 0; + + for (index, (_, dir_path)) in dirs.iter().enumerate() { + let normalized = dir_path.trim_end_matches('/'); + let candidate = if normalized == "/" || normalized.is_empty() { + Some(path.trim_start_matches('/').to_string()) + } else if path == normalized { + Some(".".to_string()) + } else if path.starts_with(normalized) + && path.as_bytes().get(normalized.len()) == Some(&b'/') + { + Some(path[normalized.len() + 1..].to_string()) + } else { + None + }; + + if let Some(relative) = candidate { + let prefix_len = if normalized == "/" { + 1 + } else { + normalized.len() + }; + if prefix_len >= best_prefix_len { + best_prefix_len = prefix_len; + best_match = Some((index, relative)); + } + } + } + + best_match +} + fn set_path_times( path: &str, atime_secs: f64, @@ -104,37 +140,7 @@ fn set_path_times( let dirs = wasi_fs_preopens::get_directories(); - // Find the best matching preopened directory (longest prefix) - let mut best_match: Option<(usize, String)> = None; - let mut best_prefix_len: usize = 0; - - for (i, (_, dir_path)) in dirs.iter().enumerate() { - let normalized = dir_path.trim_end_matches('/'); - if normalized == "/" || normalized.is_empty() { - let relative = path.trim_start_matches('/').to_string(); - let prefix_len = if normalized == "/" { 1 } else { 0 }; - if prefix_len >= best_prefix_len { - best_prefix_len = prefix_len; - best_match = Some((i, relative)); - } - } else if path == normalized { - let prefix_len = normalized.len(); - if prefix_len >= best_prefix_len { - best_prefix_len = prefix_len; - best_match = Some((i, ".".to_string())); - } - } else if path.starts_with(normalized) - && path.as_bytes().get(normalized.len()) == Some(&b'/') - { - let prefix_len = normalized.len(); - if prefix_len >= best_prefix_len { - best_prefix_len = prefix_len; - best_match = Some((i, path[normalized.len() + 1..].to_string())); - } - } - } - - if let Some((idx, relative)) = best_match { + if let Some((idx, relative)) = resolve_preopen_relative(&dirs, path) { dirs[idx] .0 .set_times_at(path_flags, &relative, atime, mtime) @@ -147,6 +153,28 @@ fn set_path_times( } } +fn symlink_at_path(target: &str, path: &str) -> std::io::Result<()> { + if std::path::Path::new(target).is_absolute() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "WASI symbolic link targets must be relative", + )); + } + + let dirs = wasi_fs_preopens::get_directories(); + let Some((index, relative)) = resolve_preopen_relative(&dirs, path) else { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "no matching preopened directory", + )); + }; + + dirs[index] + .0 + .symlink_at(target, &relative) + .map_err(|error| wasi_fs_error_to_io(&error)) +} + const MODE_PERMISSION_MASK: u32 = 0o7777; fn with_fs( @@ -244,137 +272,90 @@ fn rename_fd_path(ctx: &rquickjs::Ctx<'_>, old_path: &str, new_path: &str) { }); } -fn set_emulated_symlink(ctx: &rquickjs::Ctx<'_>, path: &str, target: &str) { - with_fs_mut(ctx, |fs| { - fs.emulated_symlinks - .insert(path.to_string(), target.to_string()); - }); -} - -fn get_emulated_symlink_target(ctx: &rquickjs::Ctx<'_>, path: &str) -> Option { - with_fs(ctx, |fs| fs.emulated_symlinks.get(path).cloned()) -} - -fn remove_emulated_symlink(ctx: &rquickjs::Ctx<'_>, path: &str) { - with_fs_mut(ctx, |fs| { - fs.emulated_symlinks.remove(path); - }); -} - -fn remove_emulated_symlinks_under(ctx: &rquickjs::Ctx<'_>, dir: &str) { - let prefix = if dir.ends_with('/') { - dir.to_string() - } else { - format!("{dir}/") - }; - with_fs_mut(ctx, |fs| { - fs.emulated_symlinks.retain(|k, _| !k.starts_with(&prefix)) - }); -} - -fn move_emulated_symlink(ctx: &rquickjs::Ctx<'_>, old_path: &str, new_path: &str) { - with_fs_mut(ctx, |fs| { - if let Some(target) = fs.emulated_symlinks.remove(old_path) { - fs.emulated_symlinks.insert(new_path.to_string(), target); - } - }); -} - -fn apply_emulated_symlink_to_stat_obj<'js>(stat_obj: &rquickjs::Object<'js>) { - stat_obj.set("isFile", false).unwrap(); - stat_obj.set("isDirectory", false).unwrap(); - stat_obj.set("isSymlink", true).unwrap(); +pub(super) fn realpath_for_module_resolution( + _ctx: &rquickjs::Ctx<'_>, + path: &str, +) -> Option { + realpath_for_module_resolution_path(path) } -/// Resolve emulated symlinks in a path by walking each component and following -/// symlink chains. Returns an ELOOP error if too many symlinks are followed. -fn resolve_emulated_symlinks_checked( - ctx: &rquickjs::Ctx<'_>, - path: &str, -) -> std::io::Result { - with_fs(ctx, |fs| { - resolve_emulated_symlinks_from(&fs.emulated_symlinks, path) - }) +pub(super) fn realpath_for_module_resolution_path(path: &str) -> Option { + canonicalize_guest_path(path).ok() } -fn resolve_emulated_symlinks_from( - emulated_symlinks: &HashMap, - path: &str, -) -> std::io::Result { - if emulated_symlinks.is_empty() { - return Ok(path.to_string()); +fn canonicalize_guest_path(path: &str) -> std::io::Result { + match std::fs::canonicalize(path) { + Ok(resolved) => Ok(resolved.to_string_lossy().to_string()), + Err(original_error) => match canonicalize_guest_path_fallback(path) { + Ok(resolved) => Ok(resolved), + Err(fallback_error) + if fallback_error + .to_string() + .contains("too many levels of symbolic links") => + { + Err(fallback_error) + } + Err(_) => Err(original_error), + }, } +} - const MAX_SYMLINK_FOLLOWS: usize = 40; - let mut symlink_count = 0; - - // Build absolute path +fn canonicalize_guest_path_fallback(path: &str) -> std::io::Result { if !path.starts_with('/') { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, - "emulated symlink resolution requires an absolute path", + "realpath requires an absolute path", )); } - let abs_path = path.to_string(); - // Split into segments to process - let mut todo: Vec = abs_path + const MAX_SYMLINK_FOLLOWS: usize = 40; + let mut symlink_count = 0; + let mut todo = path .split('/') - .filter(|s| !s.is_empty()) + .filter(|segment| !segment.is_empty()) .map(String::from) - .collect(); - let mut resolved: Vec = Vec::new(); - let mut i = 0; - - while i < todo.len() { - let seg = todo[i].clone(); - - if seg == "." { - i += 1; - continue; - } - - if seg == ".." { - resolved.pop(); - i += 1; - continue; + .collect::>(); + let mut resolved = Vec::::new(); + let mut index = 0; + + while index < todo.len() { + match todo[index].as_str() { + "." => { + index += 1; + continue; + } + ".." => { + resolved.pop(); + index += 1; + continue; + } + segment => resolved.push(segment.to_string()), } - resolved.push(seg); let current = format!("/{}", resolved.join("/")); - - if let Some(target) = emulated_symlinks.get(¤t).cloned() { + let metadata = std::fs::symlink_metadata(¤t)?; + if metadata.is_symlink() { symlink_count += 1; if symlink_count > MAX_SYMLINK_FOLLOWS { return Err(std::io::Error::other("too many levels of symbolic links")); } - // Remove the symlink component + let target = std::fs::read_link(¤t)?; + let target = target.to_string_lossy(); + let remaining = todo[index + 1..].to_vec(); resolved.pop(); - - // Collect remaining segments after the symlink - let remaining: Vec = todo[i + 1..].to_vec(); - - // Parse target into segments - let target_segments: Vec = target - .split('/') - .filter(|s| !s.is_empty()) - .map(String::from) - .collect(); - if target.starts_with('/') { - // Absolute target: clear resolved, restart from root resolved.clear(); - todo = target_segments; - todo.extend(remaining); - } else { - // Relative target: prepend to remaining - todo = target_segments; - todo.extend(remaining); } - i = 0; + todo = target + .split('/') + .filter(|segment| !segment.is_empty()) + .map(String::from) + .collect(); + todo.extend(remaining); + index = 0; } else { - i += 1; + index += 1; } } @@ -385,30 +366,6 @@ fn resolve_emulated_symlinks_from( } } -pub(super) fn realpath_for_module_resolution_with_symlinks( - emulated_symlinks: &HashMap, - path: &str, -) -> Option { - let resolved_path = resolve_emulated_symlinks_from(emulated_symlinks, path).ok()?; - std::fs::symlink_metadata(&resolved_path).ok()?; - Some(resolved_path) -} - -/// Resolve emulated symlinks in a path. Falls back to the original path on error. -fn resolve_emulated_symlinks(ctx: &rquickjs::Ctx<'_>, path: &str) -> String { - resolve_emulated_symlinks_checked(ctx, path).unwrap_or_else(|_| path.to_string()) -} - -pub(super) fn realpath_for_module_resolution( - ctx: &rquickjs::Ctx<'_>, - path: &str, -) -> Option { - let resolved_path = resolve_emulated_symlinks_checked(ctx, path).ok()?; - std::fs::symlink_metadata(&resolved_path) - .ok() - .map(|_| resolved_path) -} - fn map_error_code(err: &std::io::Error) -> (&'static str, i32, &'static str) { match err.kind() { std::io::ErrorKind::NotFound => ("ENOENT", -2, "no such file or directory"), @@ -566,7 +523,9 @@ fn metadata_to_obj<'js>( obj.set("dev", 0_f64).unwrap(); obj.set("ino", 0_f64).unwrap(); - let mode: f64 = if meta.is_dir() { + let mode: f64 = if meta.is_symlink() { + 41471.0 // 0o120777 + } else if meta.is_dir() { 16877.0 // 0o40755 } else { 33188.0 // 0o100644 @@ -780,7 +739,6 @@ pub mod native_module { match std::fs::remove_file(Path::new(&fs_path)) { Ok(_) => { super::remove_mode_override_for_path(&ctx, &fs_path); - super::remove_emulated_symlink(&ctx, &fs_path); None } Err(err) => Some(super::make_fs_error(&ctx, &err, "unlink", Some(&path))), @@ -795,7 +753,6 @@ pub mod native_module { Ok(_) => { super::move_mode_override_for_path(&ctx, &old_fs_path, &new_fs_path); super::rename_fd_path(&ctx, &old_fs_path, &new_fs_path); - super::move_emulated_symlink(&ctx, &old_fs_path, &new_fs_path); None } Err(err) => Some(super::make_fs_error_with_dest( @@ -839,7 +796,7 @@ pub mod native_module { return result; } - let fs_path = super::resolve_emulated_symlinks(&ctx, &runtime_path(&ctx, &path)); + let fs_path = runtime_path(&ctx, &path); let mut opts = OpenOptions::new(); @@ -1150,7 +1107,7 @@ pub mod native_module { return result; } - let fs_path = super::resolve_emulated_symlinks(&ctx, &runtime_path(&ctx, &path)); + let fs_path = runtime_path(&ctx, &path); match std::fs::metadata(&fs_path) { Ok(meta) => { @@ -1189,26 +1146,15 @@ pub mod native_module { return result; } - // For lstat: if the path itself is an emulated symlink, use the - // original path (we'll mark it as symlink below). Otherwise resolve - // intermediate symlinks so paths through symlinks work. let absolute_path = runtime_path(&ctx, &path); - let fs_path = if super::get_emulated_symlink_target(&ctx, &absolute_path).is_some() { - absolute_path.clone() - } else { - super::resolve_emulated_symlinks(&ctx, &absolute_path) - }; - match std::fs::symlink_metadata(&fs_path) { + match std::fs::symlink_metadata(&absolute_path) { Ok(meta) => { let stat_obj = super::metadata_to_obj(&ctx, &meta); if let Some(mode_override) = super::get_mode_override_for_path(&ctx, &absolute_path) { super::apply_mode_override_to_stat_obj(&stat_obj, mode_override); } - if super::get_emulated_symlink_target(&ctx, &absolute_path).is_some() { - super::apply_emulated_symlink_to_stat_obj(&stat_obj); - } result.set("stat", stat_obj).unwrap(); } Err(err) => { @@ -1287,7 +1233,7 @@ pub mod native_module { return result; } - let fs_path = super::resolve_emulated_symlinks(&ctx, &runtime_path(&ctx, &path)); + let fs_path = runtime_path(&ctx, &path); match std::fs::read_dir(&fs_path) { Ok(entries) => { @@ -1338,7 +1284,7 @@ pub mod native_module { return Some(super::wizer_enoent_obj(&ctx, "access", Some(&path))); } - let fs_path = super::resolve_emulated_symlinks(&ctx, &runtime_path(&ctx, &path)); + let fs_path = runtime_path(&ctx, &path); // For WASI, just check if the path exists (and is accessible) match std::fs::metadata(&fs_path) { @@ -1366,27 +1312,12 @@ pub mod native_module { return result; } - // Use chain-resolving emulated symlink resolution let absolute_path = runtime_path(&ctx, &path); - match super::resolve_emulated_symlinks_checked(&ctx, &absolute_path) { + match super::canonicalize_guest_path(&absolute_path) { Ok(resolved_path) => { - // Verify the final resolved path exists - match std::fs::symlink_metadata(&resolved_path) { - Ok(_) => { - result.set("result", resolved_path).unwrap(); - } - Err(err) => { - result - .set( - "error", - super::make_fs_error(&ctx, &err, "realpath", Some(&path)), - ) - .unwrap(); - } - } + result.set("result", resolved_path).unwrap(); } Err(err) => { - // ELOOP or other resolution error result .set( "error", @@ -1447,27 +1378,13 @@ pub mod native_module { #[rquickjs::function] pub fn fs_symlink(ctx: Ctx<'_>, target: String, path: String) -> Option> { - let fs_path = runtime_path(&ctx, &path); - if Path::new(&fs_path).exists() { - let err = std::io::Error::new(std::io::ErrorKind::AlreadyExists, "file already exists"); - return Some(super::make_fs_error_with_dest( - &ctx, - &err, - "symlink", - Some(&target), - Some(&path), - )); + if crate::internal::is_wizer_active() { + return Some(super::wizer_enoent_obj(&ctx, "symlink", Some(&path))); } - match std::fs::OpenOptions::new() - .create_new(true) - .write(true) - .open(&fs_path) - { - Ok(_) => { - super::set_emulated_symlink(&ctx, &fs_path, &target); - None - } + let fs_path = runtime_path(&ctx, &path); + match super::symlink_at_path(&target, &fs_path) { + Ok(()) => None, Err(err) => Some(super::make_fs_error_with_dest( &ctx, &err, @@ -1483,11 +1400,6 @@ pub mod native_module { let result = Object::new(ctx.clone()).unwrap(); let fs_path = runtime_path(&ctx, &path); - if let Some(target) = super::get_emulated_symlink_target(&ctx, &fs_path) { - result.set("result", target).unwrap(); - return result; - } - if crate::internal::is_wizer_active() { result .set( @@ -1683,7 +1595,6 @@ pub mod native_module { match result { Ok(_) => { super::remove_mode_override_for_path(&ctx, &fs_path); - super::remove_emulated_symlinks_under(&ctx, &fs_path); None } Err(err) => Some(super::make_fs_error(&ctx, &err, "rm", Some(&path))), @@ -1692,7 +1603,6 @@ pub mod native_module { match std::fs::remove_file(&fs_path) { Ok(_) => { super::remove_mode_override_for_path(&ctx, &fs_path); - super::remove_emulated_symlink(&ctx, &fs_path); None } Err(err) => Some(super::make_fs_error(&ctx, &err, "rm", Some(&path))), @@ -1800,7 +1710,7 @@ pub mod native_module { #[rquickjs::function] pub fn fs_exists(ctx: Ctx<'_>, path: String) -> bool { - let fs_path = super::resolve_emulated_symlinks(&ctx, &runtime_path(&ctx, &path)); + let fs_path = runtime_path(&ctx, &path); std::path::Path::new(&fs_path).exists() } diff --git a/crates/wasm-rquickjs/skeleton/src/builtin/mod.rs b/crates/wasm-rquickjs/skeleton/src/builtin/mod.rs index b02e7d0b..65c8186f 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin/mod.rs +++ b/crates/wasm-rquickjs/skeleton/src/builtin/mod.rs @@ -116,11 +116,8 @@ pub(crate) fn realpath_for_module_resolution( fs::realpath_for_module_resolution(ctx, path) } -pub(crate) fn realpath_for_module_resolution_with_symlinks( - emulated_symlinks: &std::collections::HashMap, - path: &str, -) -> Option { - fs::realpath_for_module_resolution_with_symlinks(emulated_symlinks, path) +pub(crate) fn realpath_for_module_resolution_path(path: &str) -> Option { + fs::realpath_for_module_resolution_path(path) } pub fn add_module_resolvers( diff --git a/crates/wasm-rquickjs/skeleton/src/builtin_p3.rs b/crates/wasm-rquickjs/skeleton/src/builtin_p3.rs index 5c1da4eb..6a0efc22 100644 --- a/crates/wasm-rquickjs/skeleton/src/builtin_p3.rs +++ b/crates/wasm-rquickjs/skeleton/src/builtin_p3.rs @@ -182,11 +182,8 @@ pub(crate) fn realpath_for_module_resolution( fs::realpath_for_module_resolution(ctx, path) } -pub(crate) fn realpath_for_module_resolution_with_symlinks( - emulated_symlinks: &std::collections::HashMap, - path: &str, -) -> Option { - fs::realpath_for_module_resolution_with_symlinks(emulated_symlinks, path) +pub(crate) fn realpath_for_module_resolution_path(path: &str) -> Option { + fs::realpath_for_module_resolution_path(path) } /// Registers builtin native and JavaScript module names with the resolver. diff --git a/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs b/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs index f8efeb0f..75321794 100644 --- a/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs +++ b/crates/wasm-rquickjs/skeleton/src/internal/module_loading.rs @@ -3660,24 +3660,16 @@ impl NodeFileResolver { if preserve_symlinks { return normalized.to_string(); } - let realpath_input = crate::builtin::realpath_for_module_resolution(ctx, normalized) - .unwrap_or_else(|| normalized.to_string()); - std::fs::canonicalize(&realpath_input) - .map(|path| CjsEvalResolver::normalize_path(&path)) - .unwrap_or(realpath_input) - } - - fn module_resolution_path(ctx: &Ctx<'_>, normalized: &str) -> String { crate::builtin::realpath_for_module_resolution(ctx, normalized) .unwrap_or_else(|| normalized.to_string()) } - fn module_resolution_is_file(ctx: &Ctx<'_>, normalized: &str) -> bool { - std::path::Path::new(&Self::module_resolution_path(ctx, normalized)).is_file() + fn module_resolution_is_file(_ctx: &Ctx<'_>, normalized: &str) -> bool { + std::path::Path::new(normalized).is_file() } - fn module_resolution_is_dir(ctx: &Ctx<'_>, normalized: &str) -> bool { - std::path::Path::new(&Self::module_resolution_path(ctx, normalized)).is_dir() + fn module_resolution_is_dir(_ctx: &Ctx<'_>, normalized: &str) -> bool { + std::path::Path::new(normalized).is_dir() } fn resolve_candidate( @@ -4225,7 +4217,6 @@ struct NodePackageResolutionContext<'a, 'w> { conditions: &'a [String], warnings: &'w mut Vec, file_probe_cache: HashMap, - emulated_symlinks: HashMap, package_json_cache: PackageJsonCache, } @@ -4236,13 +4227,6 @@ impl<'a, 'w> NodePackageResolutionContext<'a, 'w> { conditions: &'a [String], warnings: &'w mut Vec, ) -> Self { - let emulated_symlinks = ctx - .userdata::() - .expect("runtime services not initialized") - .fs - .borrow() - .emulated_symlinks - .clone(); let package_json_cache = ctx .userdata::() .expect("runtime services not initialized") @@ -4253,7 +4237,6 @@ impl<'a, 'w> NodePackageResolutionContext<'a, 'w> { conditions, warnings, file_probe_cache: HashMap::new(), - emulated_symlinks, package_json_cache, } } @@ -4262,12 +4245,7 @@ impl<'a, 'w> NodePackageResolutionContext<'a, 'w> { if let Some(cached) = self.file_probe_cache.get(normalized) { return *cached; } - let fs_path = crate::builtin::realpath_for_module_resolution_with_symlinks( - &self.emulated_symlinks, - normalized, - ) - .unwrap_or_else(|| normalized.to_string()); - let is_file = std::path::Path::new(&fs_path).is_file(); + let is_file = std::path::Path::new(normalized).is_file(); self.file_probe_cache .insert(normalized.to_string(), is_file); is_file @@ -4279,13 +4257,7 @@ impl<'a, 'w> NodePackageResolutionContext<'a, 'w> { } fn is_dir(&self, path: &std::path::Path) -> bool { - let normalized = CjsEvalResolver::normalize_path(path); - let fs_path = crate::builtin::realpath_for_module_resolution_with_symlinks( - &self.emulated_symlinks, - &normalized, - ) - .unwrap_or(normalized); - std::path::Path::new(&fs_path).is_dir() + path.is_dir() } fn with_mode( @@ -4326,15 +4298,12 @@ enum CjsAnalysisProbe { impl NodeModulesResolver { fn module_resolution_path( path: &std::path::Path, - resolution: &NodePackageResolutionContext<'_, '_>, + _resolution: &NodePackageResolutionContext<'_, '_>, ) -> std::path::PathBuf { let normalized = CjsEvalResolver::normalize_path(path); - crate::builtin::realpath_for_module_resolution_with_symlinks( - &resolution.emulated_symlinks, - &normalized, - ) - .map(std::path::PathBuf::from) - .unwrap_or_else(|| path.to_path_buf()) + crate::builtin::realpath_for_module_resolution_path(&normalized) + .map(std::path::PathBuf::from) + .unwrap_or_else(|| path.to_path_buf()) } fn try_resolve_with_context( diff --git a/crates/wasm-rquickjs/skeleton/src/internal/runtime_services.rs b/crates/wasm-rquickjs/skeleton/src/internal/runtime_services.rs index 5badd57c..47270f5c 100644 --- a/crates/wasm-rquickjs/skeleton/src/internal/runtime_services.rs +++ b/crates/wasm-rquickjs/skeleton/src/internal/runtime_services.rs @@ -50,7 +50,6 @@ pub(crate) struct FsServices { pub(crate) path_mode_overrides: HashMap, pub(crate) fd_mode_overrides: HashMap, pub(crate) fd_paths: HashMap, - pub(crate) emulated_symlinks: HashMap, } impl Default for FsServices { @@ -61,7 +60,6 @@ impl Default for FsServices { path_mode_overrides: HashMap::new(), fd_mode_overrides: HashMap::new(), fd_paths: HashMap::new(), - emulated_symlinks: HashMap::new(), } } } diff --git a/examples/runtime/cjs-require/src/cjs-require.js b/examples/runtime/cjs-require/src/cjs-require.js index 420b6f69..1d133605 100644 --- a/examples/runtime/cjs-require/src/cjs-require.js +++ b/examples/runtime/cjs-require/src/cjs-require.js @@ -135,7 +135,7 @@ export const testRequireDirectory = () => { assert.throws(() => appRequire.resolve('./b'), TypeError, 'primitive path cache fails when resolution writes through it'); fs.writeFileSync('/path-cache-app/real-link-target.js', 'module.exports = { link: true };'); - fs.symlinkSync('/path-cache-app/real-link-target.js', '/path-cache-app/link-target.js'); + fs.symlinkSync('real-link-target.js', '/path-cache-app/link-target.js'); Module._pathCache = Object.create(null); assert.deepStrictEqual(appRequire('./link-target.js'), { link: true }); const relativeSymlinkResolved = appRequire.resolve('./link-target.js'); @@ -154,7 +154,7 @@ export const testRequireDirectory = () => { fs.mkdirSync('/path-cache-app/packages/real-pkg', { recursive: true }); fs.writeFileSync('/path-cache-app/packages/real-pkg/index.js', 'module.exports = { symlink: true };'); - fs.symlinkSync('/path-cache-app/packages/real-pkg', '/path-cache-app/node_modules/symlink-pkg', 'dir'); + fs.symlinkSync('../packages/real-pkg', '/path-cache-app/node_modules/symlink-pkg', 'dir'); Module._pathCache = Object.create(null); assert.deepStrictEqual(appRequire('symlink-pkg'), { symlink: true }); const symlinkResolved = appRequire.resolve('symlink-pkg'); diff --git a/examples/runtime/execution/src/execution.js b/examples/runtime/execution/src/execution.js index b152ce79..d57a4372 100644 --- a/examples/runtime/execution/src/execution.js +++ b/examples/runtime/execution/src/execution.js @@ -244,6 +244,49 @@ export async function run() { return { mode, link, real, renamed }; ` }); + const persistentSymlinkCreated = await runJavaScript({ source: ` + const fs = await import('node:fs'); + fs.mkdirSync('/tmp/persistent-link', { recursive: true }); + fs.writeFileSync('/tmp/persistent-link/target.txt', 'persistent'); + fs.symlinkSync('target.txt', '/tmp/persistent-link/link.txt'); + let absoluteError; + try { + fs.symlinkSync('/tmp/persistent-link/target.txt', '/tmp/persistent-link/absolute.txt'); + } catch (error) { + absoluteError = error.code; + } + return { absoluteError, absoluteExists: fs.existsSync('/tmp/persistent-link/absolute.txt') }; + ` }); + const persistentSymlinkRead = await runJavaScript({ source: ` + const fs = await import('node:fs'); + return { + target: fs.readlinkSync('/tmp/persistent-link/link.txt'), + value: fs.readFileSync('/tmp/persistent-link/link.txt', 'utf8'), + realpath: fs.realpathSync('/tmp/persistent-link/link.txt'), + isSymbolicLink: fs.lstatSync('/tmp/persistent-link/link.txt').isSymbolicLink(), + }; + ` }); + const persistentSymlinkEdges = await runJavaScript({ source: ` + const fs = await import('node:fs'); + fs.symlinkSync('missing.txt', '/tmp/persistent-link/broken.txt'); + fs.symlinkSync('cycle-b.txt', '/tmp/persistent-link/cycle-a.txt'); + fs.symlinkSync('cycle-a.txt', '/tmp/persistent-link/cycle-b.txt'); + let cycleError; + try { fs.realpathSync('/tmp/persistent-link/cycle-a.txt'); } + catch (error) { cycleError = error.code; } + fs.renameSync('/tmp/persistent-link/link.txt', '/tmp/persistent-link/moved.txt'); + const movedTarget = fs.readlinkSync('/tmp/persistent-link/moved.txt'); + fs.unlinkSync('/tmp/persistent-link/moved.txt'); + return { + brokenExists: fs.existsSync('/tmp/persistent-link/broken.txt'), + brokenIsSymbolicLink: fs.lstatSync('/tmp/persistent-link/broken.txt').isSymbolicLink(), + brokenTarget: fs.readlinkSync('/tmp/persistent-link/broken.txt'), + cycleError, + movedTarget, + movedExistsAfterUnlink: fs.existsSync('/tmp/persistent-link/moved.txt'), + }; + ` }); + const isolationSource = ` const fs = await import('node:fs'); const label = process.env.LABEL; @@ -318,6 +361,7 @@ export async function run() { typescriptDisabled: process.features.typescript === false, disabledStripError, disabledExecutionError, imports, privateImport, removedAliases, cloneChecks, resourceError, pathAliases, + persistentSymlinkCreated, persistentSymlinkRead, persistentSymlinkEdges, cancellationError, nested, capacityError, reclaimed, isolation: { left: isolationLeft, right: isolationRight }, }); diff --git a/examples/runtime/module-resolution/src/module-resolution.js b/examples/runtime/module-resolution/src/module-resolution.js index 6b51211c..8fef8839 100644 --- a/examples/runtime/module-resolution/src/module-resolution.js +++ b/examples/runtime/module-resolution/src/module-resolution.js @@ -442,7 +442,7 @@ export const testEsmPackageMapEdgeCases = async () => { fs.mkdirSync('/esm-package-map-edge-app/node_modules/exported-pkg/subdir', { recursive: true }); fs.writeFileSync('/esm-package-map-edge-app/node_modules/exported-pkg/subdir/index.mjs', 'export default { directory: true };'); fs.symlinkSync( - '/esm-package-map-edge-app/node_modules/exported-pkg/subdir', + 'subdir', '/esm-package-map-edge-app/node_modules/exported-pkg/linked-subdir', ); fs.writeFileSync('/esm-package-map-edge-app/node_modules/exported-pkg/real.mjs', 'export default { extensionFallback: true };'); @@ -8983,7 +8983,7 @@ export const testCjsEsmDefaultSnapshotTiming = async () => { fs.writeFileSync(`${root}/symlink-target.js`, 'module.exports = { value: 1 };'); try { - fs.symlinkSync(`${root}/symlink-target.js`, `${root}/symlink-link.js`); + fs.symlinkSync('symlink-target.js', `${root}/symlink-link.js`); } catch (error) { if (!error || error.code !== 'EEXIST') { throw error; @@ -9043,8 +9043,8 @@ export const testCjsSymlinkCircularCache = async () => { fs.mkdirSync(`${moduleA}/node_modules`, { recursive: true }); fs.mkdirSync(`${moduleB}/node_modules`, { recursive: true }); - fs.symlinkSync(moduleA, moduleALink); - fs.symlinkSync(moduleB, moduleBLink); + fs.symlinkSync('../../moduleA', moduleALink); + fs.symlinkSync('../../moduleB', moduleBLink); fs.writeFileSync(`${root}/index.cjs`, 'module.exports = require("moduleA");'); fs.writeFileSync(`${moduleA}/index.js`, 'module.exports = { b: require("moduleB") };'); fs.writeFileSync(`${moduleB}/index.js`, 'module.exports = { a: require("moduleA") };'); @@ -9097,7 +9097,7 @@ export const testEsmSymlinkModuleIdentity = async () => { fs.writeFileSync(`${root}/packages/pkg/index.mjs`, 'export const url = import.meta.url; export default [];'); fs.writeFileSync(`${root}/app/entry.mjs`, "export default await import('pkg');"); try { - fs.symlinkSync(`${root}/packages/pkg`, `${root}/app/node_modules/pkg`, 'dir'); + fs.symlinkSync('../../packages/pkg', `${root}/app/node_modules/pkg`, 'dir'); } catch (error) { if (!error || error.code !== 'EEXIST') { throw error; @@ -9122,7 +9122,7 @@ export const testEsmSymlinkModuleIdentity = async () => { fs.writeFileSync(`${preserveRoot}/packages/preserve-pkg/child.mjs`, 'export default import.meta.url;'); fs.writeFileSync(`${preserveRoot}/app/entry.mjs`, "export default await import('preserve-pkg');"); try { - fs.symlinkSync(`${preserveRoot}/packages/preserve-pkg`, `${preserveRoot}/app/node_modules/preserve-pkg`, 'dir'); + fs.symlinkSync('../../packages/preserve-pkg', `${preserveRoot}/app/node_modules/preserve-pkg`, 'dir'); } catch (error) { if (!error || error.code !== 'EEXIST') { throw error; diff --git a/examples/runtime/npm-compat/src/npm-compat.js b/examples/runtime/npm-compat/src/npm-compat.js index 0c27d2c0..571344e4 100644 --- a/examples/runtime/npm-compat/src/npm-compat.js +++ b/examples/runtime/npm-compat/src/npm-compat.js @@ -17,6 +17,7 @@ async function executeNpm(args, timeoutMs) { NPM_CONFIG_FETCH_RETRIES: '0', NPM_CONFIG_PREFIX: '/prefix', NPM_CONFIG_UPDATE_NOTIFIER: 'false', + PATH: '', }, maxBytes: 4 * 1024 * 1024, timeoutMs, @@ -52,7 +53,7 @@ async function executeNpm(args, timeoutMs) { } export async function run(args) { - return executeNpm(args, 30_000); + return executeNpm(args, 60_000); } export async function runWithTimeout(args, timeoutMs) { @@ -72,9 +73,10 @@ export async function runNpx(args) { NPM_CONFIG_FUND: 'false', NPM_CONFIG_PREFIX: '/prefix', NPM_CONFIG_UPDATE_NOTIFIER: 'false', + PATH: '', }, maxBytes: 4 * 1024 * 1024, - timeoutMs: 30_000, + timeoutMs: 60_000, source: ` const originalExit = process.exit; process.exit = code => { @@ -203,6 +205,45 @@ export async function runBinDirect() { return JSON.stringify(result); } +export async function probeLinkedLayouts() { + const result = await runJavaScript({ + cwd: '/workspace', + source: ` + const fs = await import('node:fs'); + const { createRequire } = await import('node:module'); + const require = createRequire('/workspace/probe.cjs'); + const workspaceLinked = require('workspace-package'); + const workspaceReal = require('/workspace/packages/workspace-package'); + const npmLinked = require('linked-package'); + const npmReal = require('/workspace/linked-package'); + + const pnpmStore = '/workspace/node_modules/.pnpm/pnpm-package@1.0.0/node_modules/pnpm-package'; + fs.mkdirSync(pnpmStore, { recursive: true }); + fs.writeFileSync(pnpmStore + '/package.json', JSON.stringify({ + name: 'pnpm-package', version: '1.0.0', main: 'index.cjs', + })); + fs.writeFileSync(pnpmStore + '/index.cjs', 'module.exports = { identity: {} };'); + fs.symlinkSync('.pnpm/pnpm-package@1.0.0/node_modules/pnpm-package', + '/workspace/node_modules/pnpm-package', 'dir'); + const pnpmLinked = require('pnpm-package'); + const pnpmReal = require(pnpmStore); + + return { + workspaceIdentity: workspaceLinked === workspaceReal, + workspaceTarget: fs.readlinkSync('/workspace/node_modules/workspace-package'), + workspaceRealpath: fs.realpathSync('/workspace/node_modules/workspace-package'), + npmLinkIdentity: npmLinked === npmReal, + npmLinkTarget: fs.readlinkSync('/workspace/node_modules/linked-package'), + npmLinkRealpath: fs.realpathSync('/workspace/node_modules/linked-package'), + pnpmIdentity: pnpmLinked === pnpmReal, + pnpmTarget: fs.readlinkSync('/workspace/node_modules/pnpm-package'), + pnpmRealpath: fs.realpathSync('/workspace/node_modules/pnpm-package'), + }; + `, + }); + return JSON.stringify(result); +} + export function probeRuntime() { return JSON.stringify({ cwd: process.cwd(), diff --git a/examples/runtime/npm-compat/wit/npm-compat.wit b/examples/runtime/npm-compat/wit/npm-compat.wit index 90aeb7d6..432dc245 100644 --- a/examples/runtime/npm-compat/wit/npm-compat.wit +++ b/examples/runtime/npm-compat/wit/npm-compat.wit @@ -9,6 +9,7 @@ world npm-compat { export run-registry-installed: func() -> string; export run-package-formats: func() -> string; export run-bin-direct: func() -> string; + export probe-linked-layouts: func() -> string; export probe-runtime: func() -> string; export probe-primitives: func() -> string; } diff --git a/tests/node_compat/config.jsonc b/tests/node_compat/config.jsonc index 41f9b68c..9bf1e9ad 100644 --- a/tests/node_compat/config.jsonc +++ b/tests/node_compat/config.jsonc @@ -986,12 +986,12 @@ "block_01_block_01": {} } }, - "parallel/test-fs-symlink-buffer-path.js": { "category": "known-gap", "reason": "common.canCreateSymLink shim always returns false, so symlink tests are skipped" }, - "parallel/test-fs-symlink-dir.js": { "category": "known-gap", "reason": "common.canCreateSymLink shim always returns false, so symlink tests are skipped" }, - "parallel/test-fs-symlink-dir-junction-relative.js": {}, - "parallel/test-fs-symlink-dir-junction.js": {}, - "parallel/test-fs-symlink-longpath.js": {}, - "parallel/test-fs-symlink.js": { "category": "known-gap", "reason": "common.canCreateSymLink shim always returns false, so symlink tests are skipped" }, + "parallel/test-fs-symlink-buffer-path.js": { "category": "known-gap", "reason": "WASI supports persistent relative symlinks, but this vendored test requires rooted targets; common.canCreateSymLink remains false" }, + "parallel/test-fs-symlink-dir.js": { "category": "known-gap", "reason": "WASI supports persistent relative symlinks, but this vendored test requires rooted targets; common.canCreateSymLink remains false" }, + "parallel/test-fs-symlink-dir-junction-relative.js": { "category": "known-gap", "reason": "WASI symlink-at rejects the rooted symlink targets required by this vendored test; persistent relative symlinks are covered by runtime tests" }, + "parallel/test-fs-symlink-dir-junction.js": { "category": "known-gap", "reason": "WASI symlink-at rejects the rooted symlink targets required by this vendored test; persistent relative symlinks are covered by runtime tests" }, + "parallel/test-fs-symlink-longpath.js": { "category": "known-gap", "reason": "WASI symlink-at rejects the rooted symlink targets required by this vendored test; persistent relative symlinks are covered by runtime tests" }, + "parallel/test-fs-symlink.js": { "category": "known-gap", "reason": "WASI supports persistent relative symlinks, but this vendored test requires rooted targets; common.canCreateSymLink remains false" }, "parallel/test-fs-sync-fd-leak.js": { "category": "node-internals", "reason": "patches internalBinding('fs') internals via internal/test/binding" }, "parallel/test-fs-syncwritestream.js": {}, "parallel/test-fs-timestamp-parsing-error.js": {}, @@ -1093,7 +1093,7 @@ "parallel/test-fs-watch-recursive-delete.js": {}, "parallel/test-fs-watch-recursive-linux-parallel-remove.js": { "category": "wasi-impossible", "reason": "Linux-specific recursive fs.watch behavior is not applicable in WASI" }, "parallel/test-fs-watch-recursive-promise.js": {}, - "parallel/test-fs-watch-recursive-symlink.js": {}, + "parallel/test-fs-watch-recursive-symlink.js": { "category": "known-gap", "reason": "WASI symlink-at rejects the rooted symlink targets required by this vendored test; persistent relative symlinks are covered by runtime tests" }, "parallel/test-fs-watch-recursive-sync-write.js": {}, "parallel/test-fs-watch-recursive-update-file.js": {}, "parallel/test-fs-watch-recursive-validate.js": {}, @@ -5853,13 +5853,13 @@ }, "es-module/test-esm-loader-search.js": { "category": "node-internals", "reason": "imports internal/modules/esm/resolve (Node internal module)" }, "es-module/test-esm-long-path-win.js": { "category": "node-internals", "reason": "Windows-only test that also imports node:internal/modules/esm/resolve and internal/modules/run_main" }, - "es-module/test-esm-preserve-symlinks-main.js": { "category": "runnable" }, - "es-module/test-esm-preserve-symlinks.js": { "category": "runnable" }, + "es-module/test-esm-preserve-symlinks-main.js": { "category": "known-gap", "reason": "WASI symlink-at rejects the rooted symlink targets required by this vendored test; persistent relative symlinks are covered by runtime tests" }, + "es-module/test-esm-preserve-symlinks.js": { "category": "known-gap", "reason": "WASI symlink-at rejects the rooted symlink targets required by this vendored test; persistent relative symlinks are covered by runtime tests" }, "es-module/test-esm-repl-imports.js": { "category": "wasi-impossible", "reason": "requires spawning an interactive Node REPL subprocess (--interactive) and driving it via stdin; unsupported in this WASI environment" }, "es-module/test-esm-repl.js": { "category": "wasi-impossible", "reason": "requires spawning an interactive Node REPL subprocess (--interactive) and driving it via stdin; unsupported in this WASI environment" }, - "es-module/test-esm-symlink-main.js": { "category": "runnable" }, - "es-module/test-esm-symlink-type.js": { "category": "runnable" }, - "es-module/test-esm-symlink.js": { "category": "runnable" }, + "es-module/test-esm-symlink-main.js": { "category": "known-gap", "reason": "WASI symlink-at rejects the rooted symlink targets required by this vendored test; persistent relative symlinks are covered by runtime tests" }, + "es-module/test-esm-symlink-type.js": { "category": "known-gap", "reason": "WASI symlink-at rejects the rooted symlink targets required by this vendored test; persistent relative symlinks are covered by runtime tests" }, + "es-module/test-esm-symlink.js": { "category": "known-gap", "reason": "WASI symlink-at rejects the rooted symlink targets required by this vendored test; persistent relative symlinks are covered by runtime tests" }, "es-module/test-esm-type-field-errors-2.js": { "category": "runnable" }, "es-module/test-esm-type-field-errors.js": { "category": "runnable" }, "es-module/test-esm-undefined-cjs-global-like-variables.js": { "category": "runnable" }, @@ -6970,7 +6970,7 @@ "parallel/test-mime-whatwg.js": { "category": "known-gap", "reason": "util.MIMEType parsing API is not implemented" }, "parallel/test-module-builtin.js": { "category": "runnable" }, "parallel/test-module-children.js": { "category": "runnable" }, - "parallel/test-module-circular-symlinks.js": { "category": "runnable" }, + "parallel/test-module-circular-symlinks.js": { "category": "known-gap", "reason": "WASI symlink-at rejects the rooted symlink targets required by this vendored test; persistent relative symlinks are covered by runtime tests" }, "parallel/test-module-create-require-multibyte.js": { "split": true, "subtests": { @@ -7028,7 +7028,7 @@ "test_08_striptypescripttypes_source_map_when_mode_is_transform_and_s": {} } }, - "parallel/test-module-symlinked-peer-modules.js": { "category": "runnable" }, + "parallel/test-module-symlinked-peer-modules.js": { "category": "known-gap", "reason": "WASI symlink-at rejects the rooted symlink targets required by this vendored test; persistent relative symlinks are covered by runtime tests" }, "parallel/test-module-version.js": { "category": "runnable" }, "parallel/test-module-wrap.js": { "category": "runnable" }, "parallel/test-module-wrapper.js": { "category": "runnable" }, diff --git a/tests/node_compat/report.md b/tests/node_compat/report.md index 68170d1e..71c126fa 100644 --- a/tests/node_compat/report.md +++ b/tests/node_compat/report.md @@ -8,19 +8,19 @@ This report is generated from `config.jsonc` only. It does **not** run the vendo Primary compatibility is measured over the public API surface we can provide: CI-enforced passing (`runnable`) plus `known-gap`. WASI-impossible tests, engine differences, unevaluated tests, and Node.js-internals tests are acknowledged separately and excluded from the primary percentage. -**Primary compatibility (CI-enforced):** 3217/4391 (73.3%) +**Primary compatibility (CI-enforced):** 3206/4391 (73.0%) | Classification | Count | Primary % | Public inventory % | All listed % | |----------------|-------|-----------|--------------------|--------------| -| ✅ passing (runnable) | 3217 | 73.3% | 55.9% | 46.8% | -| 🧩 known gap | 1174 | 26.7% | 20.4% | 17.1% | +| ✅ passing (runnable) | 3206 | 73.0% | 55.8% | 46.6% | +| 🧩 known gap | 1185 | 27.0% | 20.6% | 17.2% | | 🚫 WASI-impossible (excluded) | 1191 | — | 20.7% | 17.3% | | ⚙️ engine difference (excluded) | 168 | — | 2.9% | 2.4% | | ❔ unevaluated (excluded) | 0 | — | 0.0% | 0.0% | | 🔒 Node.js internals (excluded) | 1123 | — | — | 16.3% | | **Total** | **6873** | | | **100.0%** | -Secondary full-public compatibility, including public tests that are currently excluded from primary: **3217/5750 (55.9%)**. +Secondary full-public compatibility, including public tests that are currently excluded from primary: **3206/5750 (55.8%)**. ## Inventory by Module @@ -47,13 +47,13 @@ Secondary full-public compatibility, including public tests that are currently e | eslint | 24 | 0 | 0 | 0 | 0 | 0 | 24 | 0.0% | 0.0% | | events | 93 | 59 | 2 | 0 | 0 | 0 | 32 | 96.7% | 96.7% | | fetch | 1 | 1 | 0 | 0 | 0 | 0 | 0 | 100.0% | 100.0% | -| fs | 482 | 373 | 12 | 21 | 5 | 0 | 71 | 96.9% | 90.8% | +| fs | 482 | 369 | 16 | 21 | 5 | 0 | 71 | 95.8% | 89.8% | | global | 11 | 4 | 5 | 0 | 0 | 0 | 2 | 44.4% | 44.4% | | heap | 22 | 0 | 0 | 15 | 7 | 0 | 0 | 0.0% | 0.0% | | http | 898 | 231 | 288 | 297 | 2 | 0 | 80 | 44.5% | 28.2% | | inspector | 95 | 1 | 0 | 93 | 0 | 0 | 1 | 100.0% | 1.1% | | internal | 53 | 1 | 0 | 0 | 0 | 0 | 52 | 100.0% | 100.0% | -| module | 174 | 129 | 25 | 7 | 1 | 0 | 12 | 83.8% | 79.6% | +| module | 174 | 122 | 32 | 7 | 1 | 0 | 12 | 79.2% | 75.3% | | net | 223 | 148 | 38 | 19 | 1 | 0 | 17 | 79.6% | 71.8% | | node | 8 | 0 | 0 | 1 | 0 | 0 | 7 | 0.0% | 0.0% | | os | 6 | 5 | 0 | 0 | 0 | 0 | 1 | 100.0% | 100.0% | @@ -684,7 +684,7 @@ Secondary full-public compatibility, including public tests that are currently e ## Classified Non-Runnable Tests -### known gap (1174) +### known gap (1185) | Reason | Count | Example entries | |--------|-------|-----------------| @@ -696,6 +696,7 @@ Secondary full-public compatibility, including public tests that are currently e | domain module depends on async_hooks, not fully working | 13 | `parallel/test-domain-promise.js#block_00_block_00`, `parallel/test-domain-promise.js#block_01_block_01`, `parallel/test-domain-promise.js#block_03_block_03`, ... (+10) | | inherited: dns.getServers()/setServers default-server behavior and validation are not Node-compatible | 12 | `parallel/test-dns.js#block_00_verify_that_setservers_handles_arrays_with_holes_and_other_o`, `parallel/test-dns.js#block_01_block_01`, `parallel/test-dns.js#block_02_block_02`, ... (+9) | | node:readline module is not yet supported in WebAssembly environment | 12 | `parallel/test-readline-keys.js`, `parallel/test-readline-position.js`, `parallel/test-readline-reopen.js`, ... (+9) | +| WASI symlink-at rejects the rooted symlink targets required by this vendored test; persistent relative symlinks are covered by runtime tests | 11 | `es-module/test-esm-preserve-symlinks-main.js`, `es-module/test-esm-preserve-symlinks.js`, `es-module/test-esm-symlink-main.js`, ... (+8) | | inherited: process.permission and --permission CLI semantics are incomplete in execPath emulation | 11 | `parallel/test-permission-allow-child-process-cli.js#block_00_guarantee_the_initial_state`, `parallel/test-permission-allow-child-process-cli.js#block_01_to_spawn_unless_allow_child_process_is_sent`, `parallel/test-permission-allow-wasi-cli.js#block_00_guarantee_the_initial_state`, ... (+8) | | inherited: the shared fixture assumes common.hasIntl=false means fatal TextDecoder construction throws ERR_NO_ICU, but this runtime supports fatal decoding without claiming full Intl/ICU compatibility | 11 | `parallel/test-whatwg-encoding-custom-textdecoder.js#block_00_test_textdecoder_utf_8_fatal_false_ignorebom_false`, `parallel/test-whatwg-encoding-custom-textdecoder.js#block_01_test_textdecoder_utf_8_fatal_false_ignorebom_true`, `parallel/test-whatwg-encoding-custom-textdecoder.js#block_02_invalid_encoders`, ... (+8) | | net.js TCP implementation incomplete - needs event handling and API fixes | 11 | `parallel/test-net-connect-nodelay.js`, `parallel/test-net-connect-paused-connection.js`, `parallel/test-net-during-close.js`, ... (+8) | @@ -737,11 +738,11 @@ Secondary full-public compatibility, including public tests that are currently e | wasi:http client does not surface 103 Early Hints as 'information' events | 4 | `parallel/test-http-early-hints.js#block_00_block_00`, `parallel/test-http-early-hints.js#block_01_block_01`, `parallel/test-http-early-hints.js#block_03_block_03`, ... (+1) | | DOMException options bag ({ name, cause }) is not implemented | 3 | `parallel/test-domexception-cause.js#block_01_block_01`, `parallel/test-domexception-cause.js#block_02_block_02`, `parallel/test-domexception-cause.js#block_03_block_03` | | MessagePort close callback, close-state checks, and closed-port errors are incomplete | 3 | `parallel/test-worker-message-port-close.js#block_00_block_00`, `parallel/test-worker-message-port-close.js#block_01_block_01`, `parallel/test-worker-message-port-close.js#block_02_block_02` | +| WASI supports persistent relative symlinks, but this vendored test requires rooted targets; common.canCreateSymLink remains false | 3 | `parallel/test-fs-symlink-buffer-path.js`, `parallel/test-fs-symlink-dir.js`, `parallel/test-fs-symlink.js` | | WASM child emulation does not support Node.js --test TAP filtering behavior | 3 | `parallel/test-runner-no-isolation-filtering.js#test_00_works_with_test_only`, `parallel/test-runner-no-isolation-filtering.js#test_01_works_with_test_name_pattern`, `parallel/test-runner-no-isolation-filtering.js#test_02_works_with_test_skip_pattern` | | WASM child emulation does not support Node.js --test reporter destination flushing | 3 | `parallel/test-runner-force-exit-flush.js#test_00_junit_reporter`, `parallel/test-runner-force-exit-flush.js#test_01_spec_reporter`, `parallel/test-runner-force-exit-flush.js#test_02_tap_reporter` | | child_process spawn() stdio stream compatibility (e.g. pipe) is incomplete in execPath emulation | 3 | `parallel/test-cwd-enoent-preload.js`, `parallel/test-cwd-enoent.js`, `parallel/test-preload.js` | | child_process.spawn pipe mode does not provide functional child.stdin | 3 | `parallel/test-stdin-pipe-large.js`, `parallel/test-stdin-pipe-resume.js`, `parallel/test-stdin-script-child-option.js` | -| common.canCreateSymLink shim always returns false, so symlink tests are skipped | 3 | `parallel/test-fs-symlink-buffer-path.js`, `parallel/test-fs-symlink-dir.js`, `parallel/test-fs-symlink.js` | | common/gc async_hooks-based GC tracking is not implemented in the WASM test shim | 3 | `sequential/test-gc-http-client-onerror.js`, `sequential/test-gc-http-client-timeout.js`, `sequential/test-gc-http-client.js` | | crypto.X509Certificate API is not implemented | 3 | `parallel/test-x509-escaping.js#block_01_test_escaping_rules_for_subject_alternative_names`, `parallel/test-x509-escaping.js#block_02_test_escaping_rules_for_authority_info_access`, `parallel/test-x509-escaping.js#block_03_test_escaping_rules_for_the_subject_field` | | dgram send() callback overload path has JS/native argument conversion bugs | 3 | `parallel/test-dgram-send-callback-buffer-length-empty-address.js`, `parallel/test-dgram-send-callback-buffer-length.js`, `parallel/test-dgram-send-callback-buffer.js` | diff --git a/tests/node_compat_config_report.rs b/tests/node_compat_config_report.rs index 7ee4626e..3a728686 100644 --- a/tests/node_compat_config_report.rs +++ b/tests/node_compat_config_report.rs @@ -251,6 +251,7 @@ fn is_accepted_module_known_gap_reason(reason: Option<&str>) -> bool { "WASM child emulation does not support --permission/--experimental-test-module-mocks flags", "WebAssembly global is missing in current runtime", "WebAssembly module loading for .wasm files is not implemented; binary input is currently treated as JS source", + "WASI symlink-at rejects the rooted symlink targets required by this vendored test; persistent relative symlinks are covered by runtime tests", "child_process execPath emulation does not fully match spawnSync({ encoding }) behavior for --check stdin runs", "child_process execPath emulation does not implement --experimental-print-required-tla diagnostics output", "child_process execPath emulation does not implement --trace-require-module warning output", diff --git a/tests/npm_compat/README.md b/tests/npm_compat/README.md index 0bf410bd..46154adc 100644 --- a/tests/npm_compat/README.md +++ b/tests/npm_compat/README.md @@ -32,15 +32,15 @@ or external executables work. | `npm config get` | Constrained | Effective cache and prefix values from isolated `npm_config_*` state are covered; config listing and mounted npmrc precedence are not covered yet. | | `npm install` | Constrained | Local `file:` and deterministic-registry pure-JavaScript dependencies work with scripts disabled; a simple local `node` postinstall is covered. Public registries, proxies, authentication, and complex resolution are not covered. | | `npm ci` | Constrained | A lockfile cleanly replaces `node_modules`, installs a local pure-JavaScript dependency, and leaves it loadable by a fresh execution job. | -| `npm ls`, `npm explain` | Constrained | `npm ls --json` reconstructs a guest-created tree, but currently reports a local `file:` dependency as invalid with `ELSPROBLEMS`; correct link identity is gated by GOL-388. `npm explain` coverage is retained separately. | -| `npm uninstall`, `npm update`, `npm dedupe` | Constrained | Local pure-JavaScript tree mutation with lifecycle scripts and bin links disabled. Revisiting a persisted `.bin` placeholder is gated by GOL-388; registry resolution and complex trees are not covered. | +| `npm ls`, `npm explain` | Constrained | `npm ls --json` reconstructs a guest-created tree, but still reports the materialized local `file:` dependency as invalid with `ELSPROBLEMS`; this is distinct from linked-package realpath identity. `npm explain` coverage is retained separately. | +| `npm uninstall`, `npm update`, `npm dedupe` | Constrained | Local pure-JavaScript tree mutation with lifecycle scripts and bin links disabled. Registry resolution and complex linked trees are not covered. | | `npm pack` | Constrained | JSON metadata and file selection for a local pure-JavaScript project are covered with `--dry-run --ignore-scripts`; archive creation is not covered yet. | | `npm run` | Constrained | Simple `node