From c993f705836c0694617c9129e59d3650fa32fedd Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Sun, 19 Jul 2026 08:36:50 +0200 Subject: [PATCH] fix(runtime): path and url default to win32 semantics on Windows --- .../src/lower/expr_call/nested_namespace.rs | 41 ++ .../native_module_dispatch/dispatch_m_p.rs | 54 +- crates/perry-runtime/src/path.rs | 501 +++++++----------- crates/perry-runtime/src/path/tests.rs | 474 +++++++++++++++++ crates/perry-runtime/src/url/node_compat.rs | 122 ++++- 5 files changed, 858 insertions(+), 334 deletions(-) create mode 100644 crates/perry-runtime/src/path/tests.rs diff --git a/crates/perry-hir/src/lower/expr_call/nested_namespace.rs b/crates/perry-hir/src/lower/expr_call/nested_namespace.rs index e30d48df19..302eab1f0e 100644 --- a/crates/perry-hir/src/lower/expr_call/nested_namespace.rs +++ b/crates/perry-hir/src/lower/expr_call/nested_namespace.rs @@ -520,6 +520,47 @@ pub(super) fn dispatch_path_subnamespace( method: &str, args: Vec, ) -> Result> { + // On Windows-host builds the default `js_path_*` runtime family resolves + // to win32 semantics (Node: `path === path.win32` on win32). The static + // posix rewrite below shares those symbols (`Expr::PathX`), so it would + // lose the explicit-posix pinning. Route `path.posix.(...)` + // through the runtime by-name dispatcher instead — `nm_dispatch_path`'s + // `("path.posix", …)` arms call the pinned `js_path_posix_*` family. On + // non-Windows hosts the base family IS posix, so the static rewrite + // stays byte-identical there. + if sub == "posix" && cfg!(windows) { + const RUNTIME_DISPATCHED: &[&str] = &[ + "join", + "dirname", + "basename", + "extname", + "isAbsolute", + "normalize", + "parse", + "format", + "toNamespacedPath", + "_makeLong", + "relative", + "resolve", + "matchesGlob", + ]; + if RUNTIME_DISPATCHED.contains(&method) { + // `path.posix.join()` with no args folds to "." like the static + // arm below (and like win32 join). + if method == "join" && args.is_empty() { + return Ok(Expr::String(".".to_string())); + } + return Ok(Expr::NativeMethodCall { + module: "path.posix".to_string(), + class_name: None, + object: None, + method: method.to_string(), + args, + }); + } + return Err(args); + } + // path..join(...) if method == "join" { if args.is_empty() { diff --git a/crates/perry-runtime/src/object/native_module_dispatch/dispatch_m_p.rs b/crates/perry-runtime/src/object/native_module_dispatch/dispatch_m_p.rs index 0728ca004b..3c3dc26b93 100644 --- a/crates/perry-runtime/src/object/native_module_dispatch/dispatch_m_p.rs +++ b/crates/perry-runtime/src/object/native_module_dispatch/dispatch_m_p.rs @@ -373,13 +373,13 @@ pub(crate) unsafe fn nm_dispatch_path(ctx: &NmCtx, module_name: &str, method_nam result = if win32 { crate::path::js_path_win32_resolve_join(result, segment) } else { - crate::path::js_path_resolve_join(result, segment) + crate::path::js_path_posix_resolve_join(result, segment) }; } if win32 { str_to_f64(crate::path::js_path_win32_resolve(result)) } else { - str_to_f64(crate::path::js_path_resolve(result)) + str_to_f64(crate::path::js_path_posix_resolve(result)) } }; let path_basename_value = |win32: bool| -> f64 { @@ -392,20 +392,24 @@ pub(crate) unsafe fn nm_dispatch_path(ctx: &NmCtx, module_name: &str, method_nam str_to_f64(crate::path::js_path_win32_basename_ext(path, ext)) } } else if ext.is_null() { - str_to_f64(crate::path::js_path_basename(path)) + str_to_f64(crate::path::js_path_posix_basename(path)) } else { - str_to_f64(crate::path::js_path_basename_ext(path, ext)) + str_to_f64(crate::path::js_path_posix_basename_ext(path, ext)) } }; match (module_name, method_name) { + // Default `path.*` — platform semantics (Node: `path === path.win32` + // on Windows). The direct `crate::path::js_path_*` calls below are + // themselves platform-dispatching; the closure-based arms select the + // family with `cfg!(windows)`. ("path", "dirname") => str_to_f64(crate::path::js_path_dirname(require_path_str_ptr(0))), - ("path", "basename") => path_basename_value(false), + ("path", "basename") => path_basename_value(cfg!(windows)), ("path", "extname") => str_to_f64(crate::path::js_path_extname(require_path_str_ptr(0))), ("path", "normalize") => { str_to_f64(crate::path::js_path_normalize(require_path_str_ptr(0))) } - ("path", "resolve") => path_resolve_value(false), - ("path", "join") => path_join_value(false), + ("path", "resolve") => path_resolve_value(cfg!(windows)), + ("path", "join") => path_join_value(cfg!(windows)), ("path", "relative") => str_to_f64(crate::path::js_path_relative( require_path_str_ptr(0), require_path_str_ptr(1), @@ -430,9 +434,11 @@ pub(crate) unsafe fn nm_dispatch_path(ctx: &NmCtx, module_name: &str, method_nam // (property reads) already worked, but method calls landed here with // module_name "path.win32" / "path.posix" and no matching arm, so they // returned undefined. win32 routes to the `js_path_win32_*` family; - // posix routes to the base `js_path_*` family (POSIX `/` semantics), - // mirroring how the static `path.win32.X()` / `path.posix.X()` forms - // lower in codegen. + // posix routes to the pinned `js_path_posix_*` family (the base + // `js_path_*` family is platform-dispatching, so it can no longer + // stand in for explicit posix). On Windows targets the static + // `path.posix.X()` forms also lower to these arms (see + // `dispatch_path_subnamespace` in perry-hir). ("path.win32", "dirname") => { str_to_f64(crate::path::js_path_win32_dirname(require_path_str_ptr(0))) } @@ -465,34 +471,36 @@ pub(crate) unsafe fn nm_dispatch_path(ctx: &NmCtx, module_name: &str, method_nam } ("path.win32", "format") => str_to_f64(crate::path::js_path_win32_format(arg(0))), ("path.posix", "dirname") => { - str_to_f64(crate::path::js_path_dirname(require_path_str_ptr(0))) + str_to_f64(crate::path::js_path_posix_dirname(require_path_str_ptr(0))) } ("path.posix", "basename") => path_basename_value(false), ("path.posix", "extname") => { - str_to_f64(crate::path::js_path_extname(require_path_str_ptr(0))) - } - ("path.posix", "normalize") => { - str_to_f64(crate::path::js_path_normalize(require_path_str_ptr(0))) + str_to_f64(crate::path::js_path_posix_extname(require_path_str_ptr(0))) } + ("path.posix", "normalize") => str_to_f64(crate::path::js_path_posix_normalize( + require_path_str_ptr(0), + )), ("path.posix", "resolve") => path_resolve_value(false), ("path.posix", "join") => path_join_value(false), - ("path.posix", "relative") => str_to_f64(crate::path::js_path_relative( + ("path.posix", "relative") => str_to_f64(crate::path::js_path_posix_relative( require_path_str_ptr(0), require_path_str_ptr(1), )), - ("path.posix", "toNamespacedPath") => crate::path::js_path_to_namespaced_path_value(arg(0)), - ("path.posix", "_makeLong") => crate::path::js_path_to_namespaced_path_value(arg(0)), - ("path.posix", "isAbsolute") => { - bool_to_f64(crate::path::js_path_is_absolute(require_path_str_ptr(0))) + ("path.posix", "toNamespacedPath") => { + crate::path::js_path_posix_to_namespaced_path_value(arg(0)) } - ("path.posix", "matchesGlob") => bool_to_f64(crate::path::js_path_matches_glob( + ("path.posix", "_makeLong") => crate::path::js_path_posix_to_namespaced_path_value(arg(0)), + ("path.posix", "isAbsolute") => bool_to_f64(crate::path::js_path_posix_is_absolute( + require_path_str_ptr(0), + )), + ("path.posix", "matchesGlob") => bool_to_f64(crate::path::js_path_posix_matches_glob( require_path_str_ptr(0), require_path_str_ptr(1), )), ("path.posix", "parse") => { - ptr_to_f64(crate::path::js_path_parse(require_path_str_ptr(0)) as *const u8) + ptr_to_f64(crate::path::js_path_posix_parse(require_path_str_ptr(0)) as *const u8) } - ("path.posix", "format") => str_to_f64(crate::path::js_path_format(arg(0))), + ("path.posix", "format") => str_to_f64(crate::path::js_path_posix_format(arg(0))), // ── util module ── _ => f64::from_bits(JSValue::undefined().bits()), diff --git a/crates/perry-runtime/src/path.rs b/crates/perry-runtime/src/path.rs index f6f22641e5..349ba09a07 100644 --- a/crates/perry-runtime/src/path.rs +++ b/crates/perry-runtime/src/path.rs @@ -1,4 +1,12 @@ //! Path module - provides path manipulation utilities +//! +//! Node parity: on win32 `path === path.win32`, so the DEFAULT `js_path_*` +//! entry points dispatch on `cfg!(windows)` at runtime — win32 semantics on +//! Windows targets, POSIX everywhere else. The explicit sub-namespaces stay +//! pinned regardless of platform: `path.win32.*` → `js_path_win32_*`, +//! `path.posix.*` → the `js_path_posix_*` family (the pre-dispatch POSIX +//! bodies of the default entry points). `cfg!` (not `#[cfg]`) keeps both +//! families compiled on every host so neither side can rot unnoticed. use std::path::Path; @@ -135,7 +143,11 @@ pub(crate) fn resolve_posix_str(path_str: &str) -> String { std::env::current_dir() .map(|cwd| cwd.to_string_lossy().to_string()) .unwrap_or_default() - } else if Path::new(path_str).is_absolute() { + // POSIX absoluteness is lexical (a leading `/`). `Path::is_absolute()` + // is byte-identical on Unix hosts but host-dependent on Windows (it + // would treat `C:\x` as absolute and `/x` as relative), which would + // break the pinned `path.posix.resolve` on Windows targets. + } else if path_str.starts_with('/') { normalize_str(path_str) } else { match std::env::current_dir() { @@ -173,10 +185,26 @@ pub(crate) fn js_path_join_unchecked( } } +/// Default `path.join(a, b)` — platform-dispatching (win32 on Windows +/// targets, POSIX elsewhere), matching Node where `path === path.win32` +/// on win32. #[no_mangle] pub extern "C" fn js_path_join( a_ptr: *const StringHeader, b_ptr: *const StringHeader, +) -> *mut StringHeader { + if cfg!(windows) { + return js_path_win32_join(a_ptr, b_ptr); + } + js_path_posix_join(a_ptr, b_ptr) +} + +/// Pinned `path.posix.join(a, b)` — the pre-dispatch POSIX body of +/// [`js_path_join`], kept callable so explicit `path.posix.*` keeps `/` +/// semantics on Windows targets. +pub(crate) fn js_path_posix_join( + a_ptr: *const StringHeader, + b_ptr: *const StringHeader, ) -> *mut StringHeader { let _ = string_from_header_or_throw(a_ptr); let _ = string_from_header_or_throw(b_ptr); @@ -447,8 +475,24 @@ fn win32_resolve_inner(path_str: &str) -> String { let path = if split.prefix.is_empty() { join_win32_paths(&cwd, path_str) } else { - let drive_cwd = format!("{}{}", split.prefix, cwd); - join_win32_paths(&drive_cwd, split.rest) + // Drive-relative input (`C:foo`) — Node resolves it against the cwd + // of that drive. + let cwd_split = split_win32(&cwd); + if cwd_split.prefix.eq_ignore_ascii_case(split.prefix) { + // Windows host: the cwd is already on the input's drive. + join_win32_paths(&cwd, split.rest) + } else if cwd_split.prefix.is_empty() { + // POSIX host: the cwd is driveless — graft the input's drive + // onto it (historical behavior, pinned by the fixtures). + let drive_cwd = format!("{}{}", split.prefix, cwd); + join_win32_paths(&drive_cwd, split.rest) + } else { + // Windows host, cwd on a DIFFERENT drive: fall back to that + // drive's root, matching Node when it has no per-drive cwd. + // (Pre-fix this grafted `C:` onto `C:\...`, yielding + // `C:C:\...` garbage on Windows hosts.) + join_win32_paths(&format!("{}\\", split.prefix), split.rest) + } }; normalize_win32_str(&path) } @@ -466,6 +510,15 @@ pub(crate) fn resolve_win32_str(path_str: &str) -> String { /// (`"//foo"` → `"//"`) both fall out of the algorithm. #[no_mangle] pub extern "C" fn js_path_dirname(path_ptr: *const StringHeader) -> *mut StringHeader { + if cfg!(windows) { + let path_str = string_from_header_or_throw(path_ptr); + return string_to_js(&win32_dirname_inner(&path_str)); + } + js_path_posix_dirname(path_ptr) +} + +/// Pinned `path.posix.dirname` — see the dirname doc comment above. +pub(crate) fn js_path_posix_dirname(path_ptr: *const StringHeader) -> *mut StringHeader { let path_str = string_from_header_or_throw(path_ptr); string_to_js(&posix_dirname_inner(&path_str)) } @@ -473,6 +526,15 @@ pub extern "C" fn js_path_dirname(path_ptr: *const StringHeader) -> *mut StringH /// Get base name (file name) from path #[no_mangle] pub extern "C" fn js_path_basename(path_ptr: *const StringHeader) -> *mut StringHeader { + if cfg!(windows) { + let path_str = string_from_header_or_throw(path_ptr); + return string_to_js(&win32_basename_inner(&path_str)); + } + js_path_posix_basename(path_ptr) +} + +/// Pinned `path.posix.basename(path)`. +pub(crate) fn js_path_posix_basename(path_ptr: *const StringHeader) -> *mut StringHeader { let path_str = string_from_header_or_throw(path_ptr); string_to_js(posix_basename_inner(&path_str)) } @@ -480,6 +542,16 @@ pub extern "C" fn js_path_basename(path_ptr: *const StringHeader) -> *mut String /// Get file extension from path (including the dot) #[no_mangle] pub extern "C" fn js_path_extname(path_ptr: *const StringHeader) -> *mut StringHeader { + if cfg!(windows) { + let path_str = string_from_header_or_throw(path_ptr); + let (ext, _) = split_extension(&win32_basename_inner(&path_str)); + return string_to_js(&ext); + } + js_path_posix_extname(path_ptr) +} + +/// Pinned `path.posix.extname(path)`. +pub(crate) fn js_path_posix_extname(path_ptr: *const StringHeader) -> *mut StringHeader { let path_str = string_from_header_or_throw(path_ptr); let path = Path::new(&path_str); @@ -496,8 +568,23 @@ pub extern "C" fn js_path_extname(path_ptr: *const StringHeader) -> *mut StringH /// Check if path is absolute #[no_mangle] pub extern "C" fn js_path_is_absolute(path_ptr: *const StringHeader) -> i32 { + if cfg!(windows) { + let path_str = string_from_header_or_throw(path_ptr); + return if !path_str.is_empty() && split_win32(&path_str).is_absolute { + 1 + } else { + 0 + }; + } + js_path_posix_is_absolute(path_ptr) +} + +/// Pinned `path.posix.isAbsolute(path)` — lexical `/` prefix check. +/// (`Path::is_absolute()` is byte-identical on Unix hosts but would apply +/// win32 rules when the runtime is built for Windows.) +pub(crate) fn js_path_posix_is_absolute(path_ptr: *const StringHeader) -> i32 { let path_str = string_from_header_or_throw(path_ptr); - if Path::new(&path_str).is_absolute() { + if path_str.starts_with('/') { 1 } else { 0 @@ -507,6 +594,15 @@ pub extern "C" fn js_path_is_absolute(path_ptr: *const StringHeader) -> i32 { /// Resolve path to absolute path #[no_mangle] pub extern "C" fn js_path_resolve(path_ptr: *const StringHeader) -> *mut StringHeader { + if cfg!(windows) { + let path_str = string_from_header_or_throw(path_ptr); + return string_to_js(&win32_resolve_inner(&path_str)); + } + js_path_posix_resolve(path_ptr) +} + +/// Pinned `path.posix.resolve(path)`. +pub(crate) fn js_path_posix_resolve(path_ptr: *const StringHeader) -> *mut StringHeader { let path_str = string_from_header_or_throw(path_ptr); string_to_js(&resolve_posix_str(&path_str)) } @@ -677,6 +773,15 @@ fn posix_dirname_inner(path: &str) -> String { #[no_mangle] pub extern "C" fn js_path_normalize(path_ptr: *const StringHeader) -> *mut StringHeader { + if cfg!(windows) { + let path_str = string_from_header_or_throw(path_ptr); + return string_to_js(&normalize_win32_str(&path_str)); + } + js_path_posix_normalize(path_ptr) +} + +/// Pinned `path.posix.normalize(path)`. +pub(crate) fn js_path_posix_normalize(path_ptr: *const StringHeader) -> *mut StringHeader { let path_str = string_from_header_or_throw(path_ptr); string_to_js(&normalize_str(&path_str)) } @@ -705,9 +810,12 @@ fn require_relative_arg(value: f64, arg_name: &str) -> *const StringHeader { /// underlying string-only helper is invoked (#2995). #[no_mangle] pub extern "C" fn js_path_relative_checked(from_f64: f64, to_f64: f64) -> *mut StringHeader { + if cfg!(windows) { + return js_path_win32_relative_checked(from_f64, to_f64); + } let from = require_relative_arg(from_f64, "from"); let to = require_relative_arg(to_f64, "to"); - js_path_relative(from, to) + js_path_posix_relative(from, to) } /// `path.win32.relative(from, to)` validating entry point — see @@ -732,6 +840,17 @@ static KEEP_PATH_WIN32_RELATIVE_CHECKED: extern "C" fn(f64, f64) -> *mut StringH pub extern "C" fn js_path_relative( from_ptr: *const StringHeader, to_ptr: *const StringHeader, +) -> *mut StringHeader { + if cfg!(windows) { + return js_path_win32_relative(from_ptr, to_ptr); + } + js_path_posix_relative(from_ptr, to_ptr) +} + +/// Pinned `path.posix.relative(from, to)`. +pub(crate) fn js_path_posix_relative( + from_ptr: *const StringHeader, + to_ptr: *const StringHeader, ) -> *mut StringHeader { let from = string_from_header_or_throw(from_ptr); let to = string_from_header_or_throw(to_ptr); @@ -755,6 +874,17 @@ pub extern "C" fn js_path_relative( pub extern "C" fn js_path_basename_ext( path_ptr: *const StringHeader, ext_ptr: *const StringHeader, +) -> *mut StringHeader { + if cfg!(windows) { + return js_path_win32_basename_ext(path_ptr, ext_ptr); + } + js_path_posix_basename_ext(path_ptr, ext_ptr) +} + +/// Pinned `path.posix.basename(path, ext)`. +pub(crate) fn js_path_posix_basename_ext( + path_ptr: *const StringHeader, + ext_ptr: *const StringHeader, ) -> *mut StringHeader { let path_str = string_from_header_or_throw(path_ptr); let ext_str = optional_suffix_from_header_or_throw(ext_ptr); @@ -767,6 +897,16 @@ pub extern "C" fn js_path_basename_ext( /// Returns a `{ root, dir, base, ext, name }` object describing the path. #[no_mangle] pub extern "C" fn js_path_parse(path_ptr: *const StringHeader) -> *mut crate::object::ObjectHeader { + if cfg!(windows) { + return js_path_win32_parse(path_ptr); + } + js_path_posix_parse(path_ptr) +} + +/// Pinned `path.posix.parse(path)`. +pub(crate) fn js_path_posix_parse( + path_ptr: *const StringHeader, +) -> *mut crate::object::ObjectHeader { use crate::object::{js_object_alloc_with_shape, js_object_set_field}; use crate::value::JSValue; @@ -893,17 +1033,25 @@ fn format_descriptor(obj_f64: f64, sep: char) -> String { /// Build a path from a `{ dir, base, root, name, ext }` descriptor. #[no_mangle] pub extern "C" fn js_path_format(obj_f64: f64) -> *mut StringHeader { + if cfg!(windows) { + return js_path_win32_format(obj_f64); + } + js_path_posix_format(obj_f64) +} + +/// Pinned `path.posix.format(pathObject)`. +pub(crate) fn js_path_posix_format(obj_f64: f64) -> *mut StringHeader { string_to_js(&format_descriptor(obj_f64, '/')) } #[no_mangle] pub extern "C" fn js_path_sep_get() -> *mut StringHeader { - string_to_js("/") + string_to_js(if cfg!(windows) { "\\" } else { "/" }) } #[no_mangle] pub extern "C" fn js_path_delimiter_get() -> *mut StringHeader { - string_to_js(":") + string_to_js(if cfg!(windows) { ";" } else { ":" }) } /// Internal helper for `path.resolve(a, b)` — like `js_path_join` but with @@ -914,6 +1062,17 @@ pub extern "C" fn js_path_delimiter_get() -> *mut StringHeader { pub extern "C" fn js_path_resolve_join( a_ptr: *const StringHeader, b_ptr: *const StringHeader, +) -> *mut StringHeader { + if cfg!(windows) { + return js_path_win32_resolve_join(a_ptr, b_ptr); + } + js_path_posix_resolve_join(a_ptr, b_ptr) +} + +/// Pinned POSIX `resolve`-join step — see [`js_path_resolve_join`]. +pub(crate) fn js_path_posix_resolve_join( + a_ptr: *const StringHeader, + b_ptr: *const StringHeader, ) -> *mut StringHeader { let a = string_from_header_or_throw(a_ptr); let b = string_from_header_or_throw(b_ptr); @@ -930,14 +1089,19 @@ pub extern "C" fn js_path_resolve_join( string_to_js(&normalize_str(&joined)) } -/// `path.toNamespacedPath(path)` — Windows-only effect on Node. On POSIX -/// it is a no-op that returns the input unchanged. Perry's path module -/// is POSIX-shaped, so we match that. +/// `path.toNamespacedPath(path)` — Windows-only effect on Node: on win32 it +/// resolves and `\\?\`-prefixes drive/UNC paths; on POSIX it is a no-op that +/// returns the input unchanged. Platform-dispatching like the rest of the +/// default family. #[no_mangle] pub extern "C" fn js_path_to_namespaced_path(path_ptr: *const StringHeader) -> *mut StringHeader { unsafe { let s = string_from_header(path_ptr).unwrap_or_default(); - string_to_js(&s) + if cfg!(windows) { + string_to_js(&win32_to_namespaced_path(&s)) + } else { + string_to_js(&s) + } } } @@ -960,6 +1124,11 @@ fn string_value_to_namespaced_path(value: f64, win32: bool) -> f64 { #[no_mangle] pub extern "C" fn js_path_to_namespaced_path_value(value: f64) -> f64 { + string_value_to_namespaced_path(value, cfg!(windows)) +} + +/// Pinned `path.posix.toNamespacedPath(value)` — always the POSIX no-op. +pub(crate) fn js_path_posix_to_namespaced_path_value(value: f64) -> f64 { string_value_to_namespaced_path(value, false) } @@ -1153,6 +1322,17 @@ fn glob_to_regex(pattern: &str) -> String { pub extern "C" fn js_path_matches_glob( path_ptr: *const StringHeader, pattern_ptr: *const StringHeader, +) -> i32 { + if cfg!(windows) { + return js_path_win32_matches_glob(path_ptr, pattern_ptr); + } + js_path_posix_matches_glob(path_ptr, pattern_ptr) +} + +/// Pinned `path.posix.matchesGlob(path, pattern)`. +pub(crate) fn js_path_posix_matches_glob( + path_ptr: *const StringHeader, + pattern_ptr: *const StringHeader, ) -> i32 { #[cfg(feature = "regex-engine")] unsafe { @@ -1625,11 +1805,10 @@ pub extern "C" fn js_path_win32_resolve_join( string_to_js(&normalize_win32_str(&joined)) } -/// Resolve a win32 path to an absolute form. If the input isn't absolute, -/// we prepend a synthetic drive root (`C:\`) — `path.win32.resolve` is -/// host-cwd-aware on Windows but Perry's runtime always runs on POSIX hosts. -/// The fixtures only exercise inputs whose first arg is already drive- -/// absolute, so this fallback never fires in the parity sweep. +/// Resolve a win32 path to an absolute form. Non-absolute inputs resolve +/// against the host cwd (`win32_resolve_inner`) — drive-aware on Windows +/// hosts, drive-grafting on POSIX hosts. The parity fixtures only exercise +/// inputs whose first arg is already drive-absolute. #[no_mangle] pub extern "C" fn js_path_win32_resolve(path_ptr: *const StringHeader) -> *mut StringHeader { unsafe { @@ -1682,292 +1861,4 @@ pub extern "C" fn js_path_win32_relative( } #[cfg(test)] -mod posix_parse_tests { - use super::parse_posix_components; - - fn parse(path: &str) -> (String, String, String, String, String) { - parse_posix_components(path) - } - - #[test] - fn final_dot_segments_are_literal_base_names() { - assert_eq!( - parse("/tmp/."), - ( - "/".to_string(), - "/tmp".to_string(), - ".".to_string(), - String::new(), - ".".to_string() - ) - ); - assert_eq!( - parse("/tmp/.."), - ( - "/".to_string(), - "/tmp".to_string(), - "..".to_string(), - String::new(), - "..".to_string() - ) - ); - } - - #[test] - fn trailing_separators_are_ignored_without_normalizing() { - assert_eq!( - parse("/foo//bar//"), - ( - "/".to_string(), - "/foo/".to_string(), - "bar".to_string(), - String::new(), - "bar".to_string() - ) - ); - assert_eq!( - parse("foo//"), - ( - String::new(), - String::new(), - "foo".to_string(), - String::new(), - "foo".to_string() - ) - ); - } - - #[test] - fn dotfile_extension_rules_match_node() { - assert_eq!( - parse("/.bashrc"), - ( - "/".to_string(), - "/".to_string(), - ".bashrc".to_string(), - String::new(), - ".bashrc".to_string() - ) - ); - assert_eq!( - parse(".profile.js"), - ( - String::new(), - String::new(), - ".profile.js".to_string(), - ".js".to_string(), - ".profile".to_string() - ) - ); - } -} - -#[cfg(all(test, feature = "regex-engine"))] -mod glob_tests { - use super::glob_to_regex; - - #[test] - fn brace_alternation_expands_to_group() { - assert_eq!(glob_to_regex("*.{md,txt}"), "^[^/]*\\.(?:md|txt)$"); - assert_eq!( - glob_to_regex("src/{app,test}.ts"), - "^src/(?:app|test)\\.ts$" - ); - } - - #[test] - fn braces_without_alternation_stay_literal() { - assert_eq!(glob_to_regex("file.{md}"), "^file\\.\\{md\\}$"); - } - - #[test] - fn extglob_positive_groups_expand() { - assert_eq!(glob_to_regex("*.@(js|ts)"), "^[^/]*\\.(?:js|ts)$"); - assert_eq!(glob_to_regex("*.+(js|ts)"), "^[^/]*\\.(?:js|ts)+$"); - assert_eq!(glob_to_regex("*.?(js|ts)"), "^[^/]*\\.(?:js|ts)?$"); - } - - #[test] - fn globstar_is_segment_aware() { - assert_eq!(glob_to_regex("a/**/c"), "^a/(?:[^/]+/)*c$"); - assert_eq!(glob_to_regex("a/**"), "^a/.*$"); - assert_eq!(glob_to_regex("a**b"), "^a[^/]*b$"); - } - - #[test] - fn pattern_backslashes_are_separators() { - assert_eq!(glob_to_regex("foo\\*"), "^foo/[^/]*$"); - } -} - -#[cfg(test)] -mod win32_normalize_tests { - use super::{ - current_dir_as_win32, join_win32_paths, normalize_win32_str, posix_cwd_as_win32_path, - win32_basename_inner, win32_dirname_inner, win32_resolve_inner, win32_to_namespaced_path, - }; - - #[test] - fn drive_relative_bare_appends_dot() { - // #1728: a bare drive ref is the drive's *current dir*, not the root. - assert_eq!(normalize_win32_str("C:"), "C:."); - assert_eq!(normalize_win32_str("c:"), "c:."); - } - - #[test] - fn trailing_separator_preserved() { - // #1728: a trailing separator the input carried is kept. - assert_eq!(normalize_win32_str(".\\"), ".\\"); - assert_eq!(normalize_win32_str("C:\\foo\\"), "C:\\foo\\"); - assert_eq!(normalize_win32_str("\\\\?\\C:\\"), "\\\\?\\C:\\"); - } - - #[test] - fn unc_root_keeps_trailing_separator() { - // #1728: a bare UNC/device root normalizes with a trailing separator. - assert_eq!( - normalize_win32_str("\\\\server\\share"), - "\\\\server\\share\\" - ); - assert_eq!( - normalize_win32_str("\\\\server\\share\\"), - "\\\\server\\share\\" - ); - // Content after the root is unaffected (no spurious trailing sep). - assert_eq!( - normalize_win32_str("\\\\server\\share\\foo\\..\\bar"), - "\\\\server\\share\\bar" - ); - assert_eq!( - normalize_win32_str("//server/share/a/b"), - "\\\\server\\share\\a\\b" - ); - } - - #[test] - fn basename_handles_unc_root_and_drive() { - // #1728: win32.basename of a UNC root is the share segment. - assert_eq!(win32_basename_inner("\\\\server\\share\\"), "share"); - assert_eq!(win32_basename_inner("\\\\server\\share\\file"), "file"); - assert_eq!(win32_basename_inner("C:\\foo\\bar\\baz.txt"), "baz.txt"); - assert_eq!(win32_basename_inner("C:foo"), "foo"); - } - - #[test] - fn dirname_preserves_input_separator_style() { - assert_eq!(win32_dirname_inner("/foo/bar"), "/foo"); - assert_eq!(win32_dirname_inner("/foo/bar/"), "/foo"); - assert_eq!(win32_dirname_inner("foo/bar/baz"), "foo/bar"); - assert_eq!(win32_dirname_inner("C:/foo/bar"), "C:/foo"); - assert_eq!(win32_dirname_inner("//server/share"), "//server/share"); - assert_eq!(win32_dirname_inner("//server/share/a"), "//server/share/"); - } - - #[test] - fn drive_relative_with_segments_unchanged() { - // The `.` is only appended when there are no segments. - assert_eq!(normalize_win32_str("C:foo"), "C:foo"); - assert_eq!(normalize_win32_str("C:.."), "C:.."); - assert_eq!(normalize_win32_str("C:foo\\bar"), "C:foo\\bar"); - } - - #[test] - fn drive_absolute_and_others_unaffected() { - // Regression guard for the cases that already matched Node. - assert_eq!(normalize_win32_str("C:\\"), "C:\\"); - assert_eq!(normalize_win32_str("C:\\foo"), "C:\\foo"); - assert_eq!(normalize_win32_str("a//b//../b"), "a\\b"); - assert_eq!(normalize_win32_str("/foo/../../../bar"), "\\bar"); - assert_eq!(normalize_win32_str(""), "."); - } - - #[test] - fn resolve_drive_relative_uses_posix_cwd_as_drive_cwd() { - let cwd = posix_cwd_as_win32_path(); - let drive_cwd = format!("C:{}", cwd); - assert_eq!( - win32_resolve_inner("C:foo"), - normalize_win32_str(&join_win32_paths(&drive_cwd, "foo")) - ); - assert_ne!(win32_resolve_inner("C:foo"), "C:\\C:foo"); - assert_eq!( - win32_resolve_inner("foo"), - normalize_win32_str(&join_win32_paths(&cwd, "foo")) - ); - } - - #[test] - fn to_namespaced_path_resolves_but_only_namespaces_drive_and_unc() { - let cwd = current_dir_as_win32().unwrap(); - let expected_relative = normalize_win32_str(&format!("{}\\foo", cwd)); - assert_eq!(win32_to_namespaced_path("foo"), expected_relative); - assert_eq!(win32_to_namespaced_path("/tmp/x"), "\\tmp\\x"); - assert_eq!(win32_to_namespaced_path("C:\\foo"), "\\\\?\\C:\\foo"); - assert_eq!( - win32_to_namespaced_path("\\\\server\\share\\file"), - "\\\\?\\UNC\\server\\share\\file" - ); - assert_eq!( - win32_to_namespaced_path("\\\\?\\C:\\already"), - "\\\\?\\C:\\already" - ); - } -} - -#[cfg(test)] -mod malloc_backed_string_arg_tests { - use super::*; - - /// Build a REAL string whose backing allocation is malloc-tracked (not - /// arena) — the shape produced by the string-append realloc path - /// (`gc_malloc_realloc` → `gc_malloc(_, GC_TYPE_STRING)`) and by Symbol - /// descriptions. Its user address classifies as `HeapGeneration::Unknown`. - fn malloc_backed_string(bytes: &[u8]) -> *mut StringHeader { - let payload = std::mem::size_of::() + bytes.len(); - let user = crate::gc::gc_malloc(payload, crate::gc::GC_TYPE_STRING) as *mut StringHeader; - assert!(!user.is_null()); - unsafe { - crate::string::init_string_header( - user, - bytes.len() as u32, - bytes.len() as u32, - bytes.len() as u32, - 0, - 0, - ); - std::ptr::copy_nonoverlapping( - bytes.as_ptr(), - (user as *mut u8).add(std::mem::size_of::()), - bytes.len(), - ); - } - user - } - - /// A malloc-backed (generation-Unknown) string is a valid `path` argument. - /// `is_string_header_ptr` used to reject every generation-Unknown pointer, - /// so `path.dirname()` (and every other `path.*` builtin) threw - /// `TypeError: The "path" argument must be of type string.` whenever a - /// program passed a string that had grown through the append realloc path. - #[test] - fn path_builtins_accept_malloc_backed_strings() { - let path = malloc_backed_string(b"/a/b/c.txt"); - assert!(is_string_header_ptr(path)); - - let dir = js_path_dirname(path); - let dir_str = unsafe { string_from_header(dir) }.expect("dirname returns a string"); - assert_eq!(dir_str, "/a/b"); - - let base = js_path_basename(path); - let base_str = unsafe { string_from_header(base) }.expect("basename returns a string"); - assert_eq!(base_str, "c.txt"); - } - - /// Forged pointers (not in the malloc registry, not in an arena) must - /// still be rejected without dereferencing the candidate header. - #[test] - fn forged_unknown_pointer_is_still_rejected() { - let bogus = 0x0000_7777_0000_1000usize as *const StringHeader; - assert!(!is_string_header_ptr(bogus)); - } -} +mod tests; diff --git a/crates/perry-runtime/src/path/tests.rs b/crates/perry-runtime/src/path/tests.rs new file mode 100644 index 0000000000..1e3d3dee71 --- /dev/null +++ b/crates/perry-runtime/src/path/tests.rs @@ -0,0 +1,474 @@ +//! Unit tests for the `path` module — split out of `path.rs` to keep that +//! file under the CI 2,000-line cap. Child module of `path`, so the parent's +//! private helpers (`parse_posix_components`, `string_to_js`, the win32 +//! inners, …) are directly accessible. + +mod posix_parse_tests { + use super::super::parse_posix_components; + + fn parse(path: &str) -> (String, String, String, String, String) { + parse_posix_components(path) + } + + #[test] + fn final_dot_segments_are_literal_base_names() { + assert_eq!( + parse("/tmp/."), + ( + "/".to_string(), + "/tmp".to_string(), + ".".to_string(), + String::new(), + ".".to_string() + ) + ); + assert_eq!( + parse("/tmp/.."), + ( + "/".to_string(), + "/tmp".to_string(), + "..".to_string(), + String::new(), + "..".to_string() + ) + ); + } + + #[test] + fn trailing_separators_are_ignored_without_normalizing() { + assert_eq!( + parse("/foo//bar//"), + ( + "/".to_string(), + "/foo/".to_string(), + "bar".to_string(), + String::new(), + "bar".to_string() + ) + ); + assert_eq!( + parse("foo//"), + ( + String::new(), + String::new(), + "foo".to_string(), + String::new(), + "foo".to_string() + ) + ); + } + + #[test] + fn dotfile_extension_rules_match_node() { + assert_eq!( + parse("/.bashrc"), + ( + "/".to_string(), + "/".to_string(), + ".bashrc".to_string(), + String::new(), + ".bashrc".to_string() + ) + ); + assert_eq!( + parse(".profile.js"), + ( + String::new(), + String::new(), + ".profile.js".to_string(), + ".js".to_string(), + ".profile".to_string() + ) + ); + } +} + +#[cfg(feature = "regex-engine")] +mod glob_tests { + use super::super::glob_to_regex; + + #[test] + fn brace_alternation_expands_to_group() { + assert_eq!(glob_to_regex("*.{md,txt}"), "^[^/]*\\.(?:md|txt)$"); + assert_eq!( + glob_to_regex("src/{app,test}.ts"), + "^src/(?:app|test)\\.ts$" + ); + } + + #[test] + fn braces_without_alternation_stay_literal() { + assert_eq!(glob_to_regex("file.{md}"), "^file\\.\\{md\\}$"); + } + + #[test] + fn extglob_positive_groups_expand() { + assert_eq!(glob_to_regex("*.@(js|ts)"), "^[^/]*\\.(?:js|ts)$"); + assert_eq!(glob_to_regex("*.+(js|ts)"), "^[^/]*\\.(?:js|ts)+$"); + assert_eq!(glob_to_regex("*.?(js|ts)"), "^[^/]*\\.(?:js|ts)?$"); + } + + #[test] + fn globstar_is_segment_aware() { + assert_eq!(glob_to_regex("a/**/c"), "^a/(?:[^/]+/)*c$"); + assert_eq!(glob_to_regex("a/**"), "^a/.*$"); + assert_eq!(glob_to_regex("a**b"), "^a[^/]*b$"); + } + + #[test] + fn pattern_backslashes_are_separators() { + assert_eq!(glob_to_regex("foo\\*"), "^foo/[^/]*$"); + } +} + +mod win32_normalize_tests { + use super::super::{ + current_dir_as_win32, join_win32_paths, normalize_win32_str, posix_cwd_as_win32_path, + win32_basename_inner, win32_dirname_inner, win32_resolve_inner, win32_to_namespaced_path, + }; + + #[test] + fn drive_relative_bare_appends_dot() { + // #1728: a bare drive ref is the drive's *current dir*, not the root. + assert_eq!(normalize_win32_str("C:"), "C:."); + assert_eq!(normalize_win32_str("c:"), "c:."); + } + + #[test] + fn trailing_separator_preserved() { + // #1728: a trailing separator the input carried is kept. + assert_eq!(normalize_win32_str(".\\"), ".\\"); + assert_eq!(normalize_win32_str("C:\\foo\\"), "C:\\foo\\"); + assert_eq!(normalize_win32_str("\\\\?\\C:\\"), "\\\\?\\C:\\"); + } + + #[test] + fn unc_root_keeps_trailing_separator() { + // #1728: a bare UNC/device root normalizes with a trailing separator. + assert_eq!( + normalize_win32_str("\\\\server\\share"), + "\\\\server\\share\\" + ); + assert_eq!( + normalize_win32_str("\\\\server\\share\\"), + "\\\\server\\share\\" + ); + // Content after the root is unaffected (no spurious trailing sep). + assert_eq!( + normalize_win32_str("\\\\server\\share\\foo\\..\\bar"), + "\\\\server\\share\\bar" + ); + assert_eq!( + normalize_win32_str("//server/share/a/b"), + "\\\\server\\share\\a\\b" + ); + } + + #[test] + fn basename_handles_unc_root_and_drive() { + // #1728: win32.basename of a UNC root is the share segment. + assert_eq!(win32_basename_inner("\\\\server\\share\\"), "share"); + assert_eq!(win32_basename_inner("\\\\server\\share\\file"), "file"); + assert_eq!(win32_basename_inner("C:\\foo\\bar\\baz.txt"), "baz.txt"); + assert_eq!(win32_basename_inner("C:foo"), "foo"); + } + + #[test] + fn dirname_preserves_input_separator_style() { + assert_eq!(win32_dirname_inner("/foo/bar"), "/foo"); + assert_eq!(win32_dirname_inner("/foo/bar/"), "/foo"); + assert_eq!(win32_dirname_inner("foo/bar/baz"), "foo/bar"); + assert_eq!(win32_dirname_inner("C:/foo/bar"), "C:/foo"); + assert_eq!(win32_dirname_inner("//server/share"), "//server/share"); + assert_eq!(win32_dirname_inner("//server/share/a"), "//server/share/"); + } + + #[test] + fn drive_relative_with_segments_unchanged() { + // The `.` is only appended when there are no segments. + assert_eq!(normalize_win32_str("C:foo"), "C:foo"); + assert_eq!(normalize_win32_str("C:.."), "C:.."); + assert_eq!(normalize_win32_str("C:foo\\bar"), "C:foo\\bar"); + } + + #[test] + fn drive_absolute_and_others_unaffected() { + // Regression guard for the cases that already matched Node. + assert_eq!(normalize_win32_str("C:\\"), "C:\\"); + assert_eq!(normalize_win32_str("C:\\foo"), "C:\\foo"); + assert_eq!(normalize_win32_str("a//b//../b"), "a\\b"); + assert_eq!(normalize_win32_str("/foo/../../../bar"), "\\bar"); + assert_eq!(normalize_win32_str(""), "."); + } + + /// POSIX host: the cwd is driveless, so a drive-relative input grafts + /// its drive onto the cwd (historical Perry behavior). + #[cfg(not(windows))] + #[test] + fn resolve_drive_relative_uses_posix_cwd_as_drive_cwd() { + let cwd = posix_cwd_as_win32_path(); + let drive_cwd = format!("C:{}", cwd); + assert_eq!( + win32_resolve_inner("C:foo"), + normalize_win32_str(&join_win32_paths(&drive_cwd, "foo")) + ); + assert_ne!(win32_resolve_inner("C:foo"), "C:\\C:foo"); + assert_eq!( + win32_resolve_inner("foo"), + normalize_win32_str(&join_win32_paths(&cwd, "foo")) + ); + } + + /// Windows host: the cwd already carries a drive. Same-drive inputs + /// resolve against the cwd; different-drive inputs fall back to that + /// drive's root (never the pre-fix `C:C:\...` graft). + #[cfg(windows)] + #[test] + fn resolve_drive_relative_uses_host_cwd_drive() { + let cwd = posix_cwd_as_win32_path(); + let cwd_drive = &cwd[..2]; // e.g. "C:" + assert!(cwd_drive.ends_with(':'), "cwd {cwd:?} has no drive prefix"); + + let same_drive_input = format!("{}foo", cwd_drive); + assert_eq!( + win32_resolve_inner(&same_drive_input), + normalize_win32_str(&join_win32_paths(&cwd, "foo")) + ); + assert!(!win32_resolve_inner(&same_drive_input).contains(":\\C:")); + + // A drive that is NOT the cwd's drive resolves from its root. + let other = if cwd_drive.eq_ignore_ascii_case("q:") { + "z:" + } else { + "q:" + }; + assert_eq!( + win32_resolve_inner(&format!("{}foo", other)), + format!("{}\\foo", other) + ); + + assert_eq!( + win32_resolve_inner("foo"), + normalize_win32_str(&join_win32_paths(&cwd, "foo")) + ); + } + + #[test] + fn to_namespaced_path_resolves_but_only_namespaces_drive_and_unc() { + let cwd = current_dir_as_win32().unwrap(); + let resolved_relative = normalize_win32_str(&format!("{}\\foo", cwd)); + // On a Windows host the cwd is drive-absolute, so the resolved + // relative path gets the `\\?\` device prefix (matching Node); on a + // POSIX host the cwd is driveless and no prefix applies. + let expected_relative = if cfg!(windows) { + format!("\\\\?\\{}", resolved_relative) + } else { + resolved_relative + }; + assert_eq!(win32_to_namespaced_path("foo"), expected_relative); + assert_eq!(win32_to_namespaced_path("/tmp/x"), "\\tmp\\x"); + assert_eq!(win32_to_namespaced_path("C:\\foo"), "\\\\?\\C:\\foo"); + assert_eq!( + win32_to_namespaced_path("\\\\server\\share\\file"), + "\\\\?\\UNC\\server\\share\\file" + ); + assert_eq!( + win32_to_namespaced_path("\\\\?\\C:\\already"), + "\\\\?\\C:\\already" + ); + } +} + +mod malloc_backed_string_arg_tests { + use super::super::*; + + /// Build a REAL string whose backing allocation is malloc-tracked (not + /// arena) — the shape produced by the string-append realloc path + /// (`gc_malloc_realloc` → `gc_malloc(_, GC_TYPE_STRING)`) and by Symbol + /// descriptions. Its user address classifies as `HeapGeneration::Unknown`. + fn malloc_backed_string(bytes: &[u8]) -> *mut StringHeader { + let payload = std::mem::size_of::() + bytes.len(); + let user = crate::gc::gc_malloc(payload, crate::gc::GC_TYPE_STRING) as *mut StringHeader; + assert!(!user.is_null()); + unsafe { + crate::string::init_string_header( + user, + bytes.len() as u32, + bytes.len() as u32, + bytes.len() as u32, + 0, + 0, + ); + std::ptr::copy_nonoverlapping( + bytes.as_ptr(), + (user as *mut u8).add(std::mem::size_of::()), + bytes.len(), + ); + } + user + } + + /// A malloc-backed (generation-Unknown) string is a valid `path` argument. + /// `is_string_header_ptr` used to reject every generation-Unknown pointer, + /// so `path.dirname()` (and every other `path.*` builtin) threw + /// `TypeError: The "path" argument must be of type string.` whenever a + /// program passed a string that had grown through the append realloc path. + /// (The `/`-separated input yields the same dirname/basename under both + /// the posix and win32 defaults, so this passes on every host.) + #[test] + fn path_builtins_accept_malloc_backed_strings() { + let path = malloc_backed_string(b"/a/b/c.txt"); + assert!(is_string_header_ptr(path)); + + let dir = js_path_dirname(path); + let dir_str = unsafe { string_from_header(dir) }.expect("dirname returns a string"); + assert_eq!(dir_str, "/a/b"); + + let base = js_path_basename(path); + let base_str = unsafe { string_from_header(base) }.expect("basename returns a string"); + assert_eq!(base_str, "c.txt"); + } + + /// Forged pointers (not in the malloc registry, not in an arena) must + /// still be rejected without dereferencing the candidate header. + #[test] + fn forged_unknown_pointer_is_still_rejected() { + let bogus = 0x0000_7777_0000_1000usize as *const StringHeader; + assert!(!is_string_header_ptr(bogus)); + } +} + +/// The `js_path_posix_*` family must behave identically on every host — +/// it backs the explicit `path.posix.*` namespace, which stays pinned to +/// `/` semantics even on Windows targets. +mod posix_pinned_tests { + use super::super::*; + + fn s(text: &str) -> *mut StringHeader { + string_to_js(text) + } + + fn read(ptr: *mut StringHeader) -> String { + unsafe { string_from_header(ptr) }.expect("result is a string") + } + + #[test] + fn posix_family_is_host_independent() { + assert_eq!(read(js_path_posix_join(s("a"), s("b"))), "a/b"); + assert_eq!(read(js_path_posix_normalize(s("a/b/../c"))), "a/c"); + assert_eq!(read(js_path_posix_dirname(s("/a/b"))), "/a"); + assert_eq!(read(js_path_posix_basename(s("/a/b.txt"))), "b.txt"); + assert_eq!(read(js_path_posix_extname(s("a/b.txt"))), ".txt"); + assert_eq!(js_path_posix_is_absolute(s("/x")), 1); + // Win32-style inputs are NOT absolute in the pinned posix namespace, + // even when the runtime is built for Windows. + assert_eq!(js_path_posix_is_absolute(s("C:\\x")), 0); + assert_eq!(js_path_posix_is_absolute(s("relative")), 0); + assert_eq!(read(js_path_posix_resolve_join(s("/a"), s("b"))), "/a/b"); + assert_eq!(read(js_path_posix_resolve_join(s("a"), s("/b"))), "/b"); + assert_eq!(read(js_path_posix_relative(s("/a/b"), s("/a/c"))), "../c"); + } + + #[test] + fn posix_to_namespaced_path_is_a_no_op() { + let value = f64::from_bits(crate::value::JSValue::string_ptr(s("/foo/bar")).bits()); + let out = js_path_posix_to_namespaced_path_value(value); + let out_ptr = crate::string::js_string_materialize_to_heap(out); + assert_eq!( + unsafe { string_from_header(out_ptr) }.as_deref(), + Some("/foo/bar") + ); + } +} + +/// The DEFAULT `js_path_*` entry points must match the host platform — +/// win32 semantics on Windows (Node: `path === path.win32`), POSIX +/// everywhere else. +mod platform_default_tests { + use super::super::*; + + fn s(text: &str) -> *mut StringHeader { + string_to_js(text) + } + + fn read(ptr: *mut StringHeader) -> String { + unsafe { string_from_header(ptr) }.expect("result is a string") + } + + fn obj_field(obj: *mut crate::object::ObjectHeader, name: &str) -> String { + let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + let val = crate::object::js_object_get_field_by_name(obj, key); + let raw = f64::from_bits(val.bits()); + let ptr = crate::value::js_jsvalue_to_string(raw); + unsafe { string_from_header(ptr) }.unwrap_or_default() + } + + #[cfg(windows)] + #[test] + fn defaults_use_win32_semantics_on_windows() { + assert_eq!(read(js_path_sep_get()), "\\"); + assert_eq!(read(js_path_delimiter_get()), ";"); + + assert_eq!(read(js_path_join(s("a"), s("b"))), "a\\b"); + assert_eq!(read(js_path_join(s("C:\\a"), s("..\\b"))), "C:\\b"); + assert_eq!(read(js_path_normalize(s("C:/a/b/../c"))), "C:\\a\\c"); + assert_eq!(read(js_path_dirname(s("C:\\a\\b"))), "C:\\a"); + assert_eq!(read(js_path_basename(s("C:\\a\\b.txt"))), "b.txt"); + assert_eq!(read(js_path_extname(s("C:\\a\\b.txt"))), ".txt"); + + assert_eq!(js_path_is_absolute(s("C:\\x")), 1); + assert_eq!(js_path_is_absolute(s("\\x")), 1); + assert_eq!(js_path_is_absolute(s("C:x")), 0); + assert_eq!(js_path_is_absolute(s("x")), 0); + + assert_eq!(read(js_path_resolve(s("C:\\a\\..\\b"))), "C:\\b"); + assert_eq!( + read(js_path_relative(s("C:\\a\\b"), s("C:\\a\\c"))), + "..\\c" + ); + + let parsed = js_path_parse(s("C:\\dir\\file.txt")); + assert_eq!(obj_field(parsed, "root"), "C:\\"); + assert_eq!(obj_field(parsed, "dir"), "C:\\dir"); + assert_eq!(obj_field(parsed, "base"), "file.txt"); + assert_eq!(obj_field(parsed, "ext"), ".txt"); + assert_eq!(obj_field(parsed, "name"), "file"); + + let value = f64::from_bits(crate::value::JSValue::string_ptr(s("C:\\foo")).bits()); + let out = js_path_to_namespaced_path_value(value); + let out_ptr = crate::string::js_string_materialize_to_heap(out); + assert_eq!( + unsafe { string_from_header(out_ptr) }.as_deref(), + Some("\\\\?\\C:\\foo") + ); + } + + #[cfg(not(windows))] + #[test] + fn defaults_use_posix_semantics_elsewhere() { + assert_eq!(read(js_path_sep_get()), "/"); + assert_eq!(read(js_path_delimiter_get()), ":"); + + assert_eq!(read(js_path_join(s("a"), s("b"))), "a/b"); + assert_eq!(read(js_path_normalize(s("a/b/../c"))), "a/c"); + assert_eq!(read(js_path_dirname(s("/a/b"))), "/a"); + assert_eq!(read(js_path_basename(s("/a/b.txt"))), "b.txt"); + assert_eq!(read(js_path_extname(s("/a/b.txt"))), ".txt"); + + assert_eq!(js_path_is_absolute(s("/x")), 1); + assert_eq!(js_path_is_absolute(s("C:\\x")), 0); + + let parsed = js_path_parse(s("/dir/file.txt")); + assert_eq!(obj_field(parsed, "root"), "/"); + assert_eq!(obj_field(parsed, "dir"), "/dir"); + assert_eq!(obj_field(parsed, "base"), "file.txt"); + + let value = f64::from_bits(crate::value::JSValue::string_ptr(s("/foo")).bits()); + let out = js_path_to_namespaced_path_value(value); + let out_ptr = crate::string::js_string_materialize_to_heap(out); + assert_eq!( + unsafe { string_from_header(out_ptr) }.as_deref(), + Some("/foo") + ); + } +} diff --git a/crates/perry-runtime/src/url/node_compat.rs b/crates/perry-runtime/src/url/node_compat.rs index 61b3c8c42c..8461f55d73 100644 --- a/crates/perry-runtime/src/url/node_compat.rs +++ b/crates/perry-runtime/src/url/node_compat.rs @@ -115,6 +115,10 @@ mod tests { .to_string() } + /// Unix-only: the `../` case needs a `/`-separated cwd for the posix + /// resolver to pop a segment — on a Windows host the backslashed cwd is + /// a single opaque segment to the pinned posix machinery. + #[cfg(not(windows))] #[test] fn path_to_file_url_posix_preserves_relative_trailing_slash() { let cwd = cwd(); @@ -148,16 +152,112 @@ mod tests { ); assert_eq!(resolve_path_to_file_url_posix(""), cwd); } + + fn str_f64(text: &str) -> f64 { + let ptr = crate::string::js_string_from_bytes(text.as_ptr(), text.len() as u32); + f64::from_bits(crate::value::JSValue::string_ptr(ptr).bits()) + } + + #[test] + fn windows_flag_defaults_to_platform() { + let undefined = f64::from_bits(crate::value::TAG_UNDEFINED); + assert_eq!(super::options_windows_flag(undefined), cfg!(windows)); + // Non-object options behave like a missing options argument. + assert_eq!(super::options_windows_flag(3.0), cfg!(windows)); + } + + #[test] + fn explicit_windows_option_wins_over_platform() { + let obj = crate::object::js_object_alloc(0, 1); + let key = crate::string::js_string_from_bytes("windows".as_ptr(), 7); + let obj_f64 = f64::from_bits(crate::value::JSValue::pointer(obj as *const u8).bits()); + + crate::object::js_object_set_field_by_name( + obj, + key, + f64::from_bits(crate::value::TAG_FALSE), + ); + assert!(!super::options_windows_flag(obj_f64)); + + crate::object::js_object_set_field_by_name( + obj, + key, + f64::from_bits(crate::value::TAG_TRUE), + ); + assert!(super::options_windows_flag(obj_f64)); + + // `{ windows: undefined }` / `{ windows: null }` fall back to the + // platform, matching Node's `options?.windows ?? isWindows`. + crate::object::js_object_set_field_by_name( + obj, + key, + f64::from_bits(crate::value::TAG_UNDEFINED), + ); + assert_eq!(super::options_windows_flag(obj_f64), cfg!(windows)); + crate::object::js_object_set_field_by_name( + obj, + key, + f64::from_bits(crate::value::TAG_NULL), + ); + assert_eq!(super::options_windows_flag(obj_f64), cfg!(windows)); + } + + #[cfg(windows)] + #[test] + fn file_url_conversions_default_to_win32_on_windows() { + let undefined = f64::from_bits(crate::value::TAG_UNDEFINED); + + let path = super::js_url_file_url_to_path(str_f64("file:///C:/tmp/x.txt"), undefined); + assert_eq!(super::string_from_js_value(path), "C:\\tmp\\x.txt"); + + let out = super::js_url_path_to_file_url(str_f64("C:\\tmp\\x.txt"), undefined); + let obj = crate::url::object_from_f64(out).expect("URL object"); + assert_eq!( + crate::url::object_prop_string(obj, "href"), + "file:///C:/tmp/x.txt" + ); + + // Relative inputs resolve against the cwd (Node uses + // `path.win32.resolve` before building the URL). + let out = super::js_url_path_to_file_url(str_f64("x.txt"), undefined); + let obj = crate::url::object_from_f64(out).expect("URL object"); + let href = crate::url::object_prop_string(obj, "href"); + let drive = std::env::current_dir().expect("cwd").to_string_lossy()[..2].to_string(); + assert!( + href.starts_with(&format!("file:///{drive}/")), + "href {href:?} does not start with the cwd drive" + ); + assert!(href.ends_with("/x.txt"), "href {href:?}"); + } + + #[cfg(not(windows))] + #[test] + fn file_url_conversions_default_to_posix_elsewhere() { + let undefined = f64::from_bits(crate::value::TAG_UNDEFINED); + + let path = super::js_url_file_url_to_path(str_f64("file:///tmp/x.txt"), undefined); + assert_eq!(super::string_from_js_value(path), "/tmp/x.txt"); + } } /// Read the `windows` option from a `{ windows }` options argument (#2975). -/// Node treats a `true` value as force-Windows, anything else (including a -/// missing/undefined options arg or `{ windows: false }`) as POSIX. Returns -/// `false` for non-object / undefined options. +/// Node's `fileURLToPath`/`pathToFileURL` default this to the PLATFORM +/// (`options?.windows ?? isWindows`): a missing options argument, a missing +/// `windows` property, or an explicit `null`/`undefined` all fall back to +/// `cfg!(windows)`; any other value wins by truthiness (`{ windows: false }` +/// forces POSIX even on Windows, `{ windows: 1 }` forces win32 anywhere). fn options_windows_flag(options: f64) -> bool { match object_from_f64(options) { - Some(opts) => crate::value::js_is_truthy(object_prop_f64(opts, "windows")) != 0, - None => false, + Some(opts) => { + let windows = object_prop_f64(opts, "windows"); + let jv = crate::value::JSValue::from_bits(windows.to_bits()); + if jv.is_undefined() || jv.is_null() { + cfg!(windows) + } else { + crate::value::js_is_truthy(windows) != 0 + } + } + None => cfg!(windows), } } @@ -401,7 +501,17 @@ pub extern "C" fn js_url_path_to_file_url(path_f64: f64, options_f64: f64) -> f6 let rest_fwd = rest.replace('\\', "/"); format!("file://{}{}", host, encode_file_url_path(&rest_fwd)) } else { - let fwd = path.replace('\\', "/"); + // Node's win32 `pathToFileURL` resolves the input against the + // cwd first (`path.win32.resolve(filepath)`), then preserves a + // trailing separator — mirroring the posix arm below. Absolute + // inputs pass through resolution unchanged (modulo + // normalization). + let preserve_trailing_sep = path.ends_with('\\') || path.ends_with('/'); + let mut resolved = crate::path::resolve_win32_str(&path); + if preserve_trailing_sep && !resolved.ends_with('\\') { + resolved.push('\\'); + } + let fwd = resolved.replace('\\', "/"); let encoded = encode_file_url_path(&fwd); if encoded.starts_with('/') { format!("file://{}", encoded)