diff --git a/Cargo.lock b/Cargo.lock index c17556127..e0f67a897 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -342,17 +342,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "document-features" version = "0.2.12" @@ -469,15 +458,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - [[package]] name = "futures" version = "0.3.31" @@ -627,108 +607,6 @@ dependencies = [ "foldhash", ] -[[package]] -name = "icu_collections" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" - -[[package]] -name = "icu_properties" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" - -[[package]] -name = "icu_provider" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - [[package]] name = "indexmap" version = "2.13.0" @@ -825,12 +703,6 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" -[[package]] -name = "litemap" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" - [[package]] name = "litrs" version = "1.0.0" @@ -997,6 +869,7 @@ dependencies = [ "nodejs-built-in-modules", "once_cell", "papaya", + "percent-encoding", "pico-args", "pnp", "rayon", @@ -1009,7 +882,6 @@ dependencies = [ "simdutf8", "thiserror", "tracing", - "url", "vfs", "walkdir", "windows", @@ -1089,15 +961,6 @@ dependencies = [ "thiserror", ] -[[package]] -name = "potential_utf" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" -dependencies = [ - "zerovec", -] - [[package]] name = "proc-macro2" version = "1.0.103" @@ -1366,12 +1229,6 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - [[package]] name = "static_assertions" version = "1.1.0" @@ -1399,17 +1256,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "thiserror" version = "2.0.18" @@ -1439,16 +1285,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "tinystr" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" -dependencies = [ - "displaydoc", - "zerovec", -] - [[package]] name = "tracing" version = "0.1.44" @@ -1503,24 +1339,6 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - [[package]] name = "uuid" version = "1.18.1" @@ -1904,35 +1722,6 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" -[[package]] -name = "writeable" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" - -[[package]] -name = "yoke" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - [[package]] name = "zerocopy" version = "0.8.27" @@ -1953,60 +1742,6 @@ dependencies = [ "syn", ] -[[package]] -name = "zerofrom" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerotrie" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "zlib-rs" version = "0.5.2" diff --git a/Cargo.toml b/Cargo.toml index 5150609a8..0b29d7136 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -95,13 +95,11 @@ simdutf8 = { version = "0.1" } thiserror = "2" tracing = "0.1" +percent-encoding = "2" pnp = { version = "0.12.8", optional = true } document-features = { version = "0.2.12", optional = true } -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] -url = "2" - [target.'cfg(any(target_os = "macos", target_os = "linux"))'.dependencies] rustix = { version = "1.1.3", features = ["fs"] } diff --git a/src/file_url.rs b/src/file_url.rs new file mode 100644 index 000000000..2389f36e4 --- /dev/null +++ b/src/file_url.rs @@ -0,0 +1,272 @@ +use std::borrow::Cow; +use std::path::PathBuf; + +use crate::ResolveError; + +/// Convert a `file://` URL specifier to a file path, or return the specifier as-is if it's not +/// a `file://` URL. Follows the Node.js `getPathFromURLPosix` / `getPathFromURLWin32` spec. +pub fn resolve_file_protocol(specifier: &str) -> Result, ResolveError> { + if !specifier.starts_with("file://") { + return Ok(Cow::Borrowed(specifier)); + } + + let after_scheme = &specifier["file://".len()..]; + + // Split off query and fragment + let (path_with_host, query_fragment) = after_scheme + .find(['?', '#']) + .map_or((after_scheme, ""), |i| (&after_scheme[..i], &after_scheme[i..])); + + // Extract hostname and pathname + // file:///path → hostname="" pathname="/path" + // file://host/path → hostname="host" pathname="/path" + // file://C:/path → drive letter, treat as pathname (no hostname) + let (hostname, pathname) = path_with_host.strip_prefix('/').map_or_else( + || { + // Check for Windows drive letter in authority position (e.g. file://C:/path) + let bytes = path_with_host.as_bytes(); + if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' { + return ("", path_with_host); + } + // file://host/... → hostname is everything before first / + path_with_host + .find('/') + .map_or((path_with_host, ""), |i| (&path_with_host[..i], &path_with_host[i + 1..])) + }, + |rest| ("", rest), + ); + + // WHATWG URL spec: "localhost" (including percent-encoded forms) is normalized to empty host + let decoded_host; + let hostname = { + decoded_host = + percent_encoding::percent_decode_str(hostname).decode_utf8_lossy().into_owned(); + if decoded_host.eq_ignore_ascii_case("localhost") { "" } else { decoded_host.as_str() } + }; + + file_url_to_path(specifier, hostname, pathname, query_fragment) +} + +/// Check if pathname contains a percent-encoded forbidden character. +/// Returns true if `%2F` (encoded `/`) is found, or on Windows also `%5C` (encoded `\`). +fn has_encoded_separators(pathname: &str) -> bool { + let bytes = pathname.as_bytes(); + let mut i = 0; + while i + 2 < bytes.len() { + if bytes[i] == b'%' + && ((bytes[i + 1] == b'2' && (bytes[i + 2] == b'F' || bytes[i + 2] == b'f')) + || (cfg!(windows) + && bytes[i + 1] == b'5' + && (bytes[i + 2] == b'C' || bytes[i + 2] == b'c'))) + { + return true; + } + i += 1; + } + false +} + +fn decode_pathname<'a>(pathname: &'a str, specifier: &str) -> Result, ResolveError> { + percent_encoding::percent_decode_str(pathname) + .decode_utf8() + .map_err(|_| ResolveError::PathNotSupported(PathBuf::from(specifier))) +} + +#[cfg(not(windows))] +fn file_url_to_path( + specifier: &str, + hostname: &str, + pathname: &str, + query_fragment: &str, +) -> Result, ResolveError> { + // POSIX: reject non-empty hostname + if !hostname.is_empty() { + return Err(ResolveError::PathNotSupported(PathBuf::from(specifier))); + } + + if has_encoded_separators(pathname) { + return Err(ResolveError::PathNotSupported(PathBuf::from(specifier))); + } + + let decoded = decode_pathname(pathname, specifier)?; + + let mut result = String::with_capacity(1 + decoded.len() + query_fragment.len()); + result.push('/'); + result.push_str(&decoded); + result.push_str(query_fragment); + Ok(Cow::Owned(result)) +} + +#[cfg(windows)] +fn file_url_to_path( + specifier: &str, + hostname: &str, + pathname: &str, + query_fragment: &str, +) -> Result, ResolveError> { + if has_encoded_separators(pathname) { + return Err(ResolveError::PathNotSupported(PathBuf::from(specifier))); + } + + let decoded = decode_pathname(pathname, specifier)?; + let decoded = decoded.replace('/', "\\"); + + let mut result = if !hostname.is_empty() { + // UNC path + format!("\\\\{hostname}\\{decoded}") + } else { + // Strip leading backslash, validate drive letter + let path = decoded.strip_prefix('\\').unwrap_or(&decoded); + let bytes = path.as_bytes(); + if bytes.len() < 2 || !bytes[0].is_ascii_alphabetic() || bytes[1] != b':' { + return Err(ResolveError::PathNotSupported(PathBuf::from(specifier))); + } + path.to_string() + }; + + result.push_str(query_fragment); + Ok(Cow::Owned(result)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn non_file_url_passthrough() { + assert_eq!(resolve_file_protocol("./foo.js").unwrap(), "./foo.js"); + assert_eq!(resolve_file_protocol("bar").unwrap(), "bar"); + assert_eq!(resolve_file_protocol("https://example.com").unwrap(), "https://example.com"); + } + + #[cfg(not(windows))] + #[test] + fn basic_file_url() { + assert_eq!( + resolve_file_protocol("file:///home/user/file.js").unwrap(), + "/home/user/file.js" + ); + assert_eq!(resolve_file_protocol("file:///tmp/test").unwrap(), "/tmp/test"); + } + + #[cfg(windows)] + #[test] + fn basic_file_url_windows() { + assert_eq!( + resolve_file_protocol("file:///C:/Users/test/file.js").unwrap(), + "C:\\Users\\test\\file.js" + ); + // Drive letter without leading slash (file://C:/...) + assert_eq!(resolve_file_protocol("file://C:/repo/main.js").unwrap(), "C:\\repo\\main.js"); + } + + #[cfg(not(windows))] + #[test] + fn percent_decoding() { + assert_eq!( + resolve_file_protocol("file:///home/user/my%20file.js").unwrap(), + "/home/user/my file.js" + ); + } + + #[cfg(windows)] + #[test] + fn percent_decoding_windows() { + assert_eq!(resolve_file_protocol("file:///C:/my%20file.js").unwrap(), "C:\\my file.js"); + } + + #[cfg(not(windows))] + #[test] + fn query_and_fragment_preserved() { + assert_eq!( + resolve_file_protocol("file:///path/to/file.js?query=1").unwrap(), + "/path/to/file.js?query=1" + ); + assert_eq!( + resolve_file_protocol("file:///path/to/file.js#fragment").unwrap(), + "/path/to/file.js#fragment" + ); + assert_eq!( + resolve_file_protocol("file:///path/to/file.js?q=1#frag").unwrap(), + "/path/to/file.js?q=1#frag" + ); + } + + #[cfg(windows)] + #[test] + fn query_and_fragment_preserved_windows() { + assert_eq!( + resolve_file_protocol("file:///C:/file.js?query=1").unwrap(), + "C:\\file.js?query=1" + ); + assert_eq!( + resolve_file_protocol("file:///C:/file.js#fragment").unwrap(), + "C:\\file.js#fragment" + ); + } + + #[cfg(not(windows))] + #[test] + fn localhost_normalized() { + assert_eq!(resolve_file_protocol("file://localhost/etc/passwd").unwrap(), "/etc/passwd"); + assert_eq!(resolve_file_protocol("file://LOCALHOST/etc/passwd").unwrap(), "/etc/passwd"); + // Percent-encoded "localhost" + assert_eq!(resolve_file_protocol("file://local%68ost/etc/passwd").unwrap(), "/etc/passwd"); + } + + #[cfg(windows)] + #[test] + fn localhost_normalized_windows() { + assert_eq!(resolve_file_protocol("file://localhost/C:/file.js").unwrap(), "C:\\file.js"); + } + + #[cfg(windows)] + #[test] + fn windows_unc_path() { + assert_eq!( + resolve_file_protocol("file://server/share/file.js").unwrap(), + "\\\\server\\share\\file.js" + ); + } + + #[cfg(windows)] + #[test] + fn windows_rejects_no_drive_letter() { + assert!(resolve_file_protocol("file:///no_drive/file.js").is_err()); + } + + #[cfg(windows)] + #[test] + fn windows_rejects_encoded_backslash() { + assert!(resolve_file_protocol("file:///C:/path%5Cto").is_err()); + assert!(resolve_file_protocol("file:///C:/path%5cto").is_err()); + } + + #[cfg(not(windows))] + #[test] + fn posix_rejects_hostname() { + assert!(resolve_file_protocol("file://remotehost/path").is_err()); + } + + #[cfg(not(windows))] + #[test] + fn posix_rejects_encoded_slash() { + assert!(resolve_file_protocol("file:///path%2Fto").is_err()); + assert!(resolve_file_protocol("file:///path%2fto").is_err()); + } + + #[test] + fn invalid_utf8_rejected() { + assert!(resolve_file_protocol("file:///path/%FF").is_err()); + } + + #[test] + fn has_encoded_separators_single_pass() { + assert!(!has_encoded_separators("normal/path")); + assert!(!has_encoded_separators("path%20with%20spaces")); + assert!(has_encoded_separators("path%2Fslash")); + assert!(has_encoded_separators("path%2fslash")); + assert!(!has_encoded_separators("%2")); + assert!(!has_encoded_separators("")); + } +} diff --git a/src/lib.rs b/src/lib.rs index 38ce27a34..564fb550d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -52,6 +52,8 @@ mod context; mod dts_resolver; mod error; mod file_system; +#[cfg(not(target_arch = "wasm32"))] +mod file_url; mod node_path; mod options; mod package_json; @@ -424,7 +426,7 @@ impl ResolverGeneric { cfg_if::cfg_if! { if #[cfg(not(target_arch = "wasm32"))] { - let specifier = resolve_file_protocol(specifier)?; + let specifier = file_url::resolve_file_protocol(specifier)?; let specifier = specifier.as_ref(); } }; @@ -1952,32 +1954,6 @@ impl ResolverGeneric { } } -#[cfg(not(target_arch = "wasm32"))] -fn resolve_file_protocol(specifier: &str) -> Result, ResolveError> { - if specifier.starts_with("file://") { - url::Url::parse(specifier) - .map_err(|_| ()) - .and_then(|url| { - url.to_file_path().map(|path| { - let mut result = path.to_string_lossy().to_string(); - // Preserve query and fragment from the URL - if let Some(query) = url.query() { - result.push('?'); - result.push_str(query); - } - if let Some(fragment) = url.fragment() { - result.push('#'); - result.push_str(fragment); - } - Cow::Owned(result) - }) - }) - .map_err(|()| ResolveError::PathNotSupported(PathBuf::from(specifier))) - } else { - Ok(Cow::Borrowed(specifier)) - } -} - /// Strip BOM in place by replacing with spaces (no reallocation) /// UTF-8 BOM is 3 bytes: 0xEF, 0xBB, 0xBF pub(crate) fn replace_bom_with_whitespace(s: &mut [u8]) { diff --git a/src/tests/resolve.rs b/src/tests/resolve.rs index 0d64f024d..867698c8e 100644 --- a/src/tests/resolve.rs +++ b/src/tests/resolve.rs @@ -278,16 +278,15 @@ fn resolve_normalized_on_windows() { #[cfg(windows)] #[test] fn file_protocol() { - use url::Url; - let f = super::fixture(); let main1_js_path = f.join("main1.js").to_string_lossy().to_string(); - let file_protocol_path = Url::from_file_path(main1_js_path.clone()).unwrap(); + // Construct file:/// URL manually: forward-slash the path and prepend file:/// + let file_protocol_path = format!("file:///{}", main1_js_path.replace('\\', "/")); let resolver = Resolver::default(); - let resolution = resolver.resolve(&f, file_protocol_path.as_str()).ok(); + let resolution = resolver.resolve(&f, &file_protocol_path).ok(); let resolved_path = resolution.as_ref().map(Resolution::full_path); assert_eq!(resolved_path, Some(f.join("main1.js")));