From 6da1d82c2e4c69f4fa2d74b61da7ead244acb6fd Mon Sep 17 00:00:00 2001 From: Sergi Gonzalez <31130069+TheHypnoo@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:45:07 +0200 Subject: [PATCH 1/3] fix(url): remove dot segments and fold special-scheme separators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `new URL()` kept `.` and `..` path segments in `pathname`/`href` because `parse_url` never had the WHATWG path-state pass: it split the authority off and stored the remaining text verbatim (#12864). Add `normalize_hierarchical_path`, which applies the path-shape rules the standard's path state contributes: single-dot (`.`, `%2e`) segments are dropped, double-dot (`..`, `.%2e`, `%2e.`, `%2e%2e`) segments shorten the path, a dot segment in final position appends an empty segment instead (`/a/..` is `/`, `/a/b/..` is `/a/`), empty segments from a `//` run survive, and the `file:`-only rules hold (a Windows drive letter canonicalizes to `:`, so `C|` becomes `C:`, and a path that is only a drive letter is never shortened). Opaque paths (`mailto:`, `data:`) keep their text. The same parse pass was missing the rest of the special-scheme shape, so it lands with it: `\` is a path separator and authority terminator for special schemes (folded before the host is read, while query/fragment keep theirs and a non-special host may not contain one), the `//` after a special scheme is optional with its separator run skipped (`https:x`, `https:/x`, `https:///x` all host `x`), `file:` keeps its own slash states (extra separators are path, empty host allowed) and now canonicalizes its host like any other special scheme. `is_valid_absolute_url` mirrors all of that instead of requiring a literal `//`, and no longer rejects an opaque URL with nothing after the colon. `js_url_set_pathname` runs the same states, since assignment clears the path and re-parses the value. Validated against the pinned Node oracle: - `RUST_TEST_THREADS=1 cargo test --release -p perry-runtime --lib url::` — 25 passed - `PERRY_SKIP_BUILD=1 ./run_parity_tests.sh --suite node-suite --module url` — 69/69 PASS - new node-parity cases print byte-identical output to Node --- changelog.d/12864-url-dot-segments.md | 20 ++ crates/perry-runtime/src/url/mod.rs | 202 ++++++++++++- crates/perry-runtime/src/url/parse.rs | 266 +++++++++++++++--- crates/perry-runtime/src/url/url_class.rs | 43 +-- .../url/url/backslash-and-separators.ts | 127 +++++++++ .../node-suite/url/url/dot-segments.ts | 91 ++++++ 6 files changed, 697 insertions(+), 52 deletions(-) create mode 100644 changelog.d/12864-url-dot-segments.md create mode 100644 test-parity/node-suite/url/url/backslash-and-separators.ts create mode 100644 test-parity/node-suite/url/url/dot-segments.ts diff --git a/changelog.d/12864-url-dot-segments.md b/changelog.d/12864-url-dot-segments.md new file mode 100644 index 00000000000..edd639dfe76 --- /dev/null +++ b/changelog.d/12864-url-dot-segments.md @@ -0,0 +1,20 @@ +Remove `.` and `..` path segments while parsing a URL, per the WHATWG "path state" (#12864). `new URL("https://x/a/b/../c").pathname` was `/a/b/../c` instead of `/a/c`, so `href` carried the unnormalized text too — the case routers and path-based access checks depend on (`/public/../admin` must read as `/admin`). + +Root cause was structural, not a dropped branch: `parse_url` never had the path-state pass at all. It split the authority off the input and stored the remaining text as `pathname` verbatim; every consumer (`create_url_object`, `new URL(str, base)`, `URL.parse`, the `href`/`pathname` setters, `fileURLToPath`) then built on that raw string. WHATWG keeps a URL's path as a *list* of segments and drops dot segments as it reads them, which is why the standard's rules are asymmetric in a way a plain `pop` loop cannot reproduce: a dot segment is dropped, but when it is the LAST thing in the path the parser appends an empty segment instead of nothing, so `/a/..` is `/` while `/a/b/..` is `/a/`. + +`crates/perry-runtime/src/url/parse.rs` gains `normalize_hierarchical_path`, the path-shape rules applied to a path string: `.`/`%2e` are single-dot segments, `..`/`.%2e`/`%2e.`/`%2e%2e` are double-dot (ASCII case-insensitive, per the standard), empty segments from a `//` run survive verbatim (`//a/../b` → `//b`), a segment that merely contains a dot is untouched (`/a/.../b`), and the `file:`-only rules apply — a Windows drive letter canonicalizes to `:` (`file:///C|/x` → `/C:/x`) and a path that is only a drive letter is never shortened (`file:///C:/..` stays `/C:/`). `parse_url` applies it to the hierarchical branches only, so an opaque path (`mailto:a/../b`, `data:`) keeps its text. + +The same parse pass also had the rest of the special-scheme shape missing, and it is one rule family, so it lands here rather than as a second defect: + +- **`\` is a separator for special schemes.** `http://x\a\b` is `http://x/a/b` and `\` terminates the authority, so folding happens before the host is read. Query and fragment keep theirs — their encode sets do not encode `\` (`?q=\a`, `#f\b`) — and a non-special path keeps them too (`foo://a/b\c` is `/b\c`). A backslash in a non-special *host* is a forbidden host code point, so `foo://a\b` is now invalid, as in Node. +- **The `//` after a special scheme is optional and its separator run is skipped.** `https:x`, `https:/x` and `https:\x` host `x`; `https:///x` and `https:////x` do too. Special schemes are therefore never classified as opaque, while `file:` keeps its own separator rules: extra separators are path (`file:////x` is an empty host with the path `//x`), its host may be empty (`file:`, `file:x`, `file:/x`), and `file:\\host` carries an authority marker written with backslashes. +- **`file:` hosts canonicalize like any other special scheme's** (`file://SERVER/x` → `server`, `file://123/x` → `0.0.0.123`, `file://LOCALHOST/x` → empty host). They previously skipped the WHATWG host parser entirely. +- **`is_valid_absolute_url` mirrors all of the above** instead of requiring a literal `//` for a special scheme, and no longer rejects an opaque URL with nothing after the colon (`mailto:` is valid, as in Node). + +`js_url_set_pathname` runs the same states, because assignment clears the path and re-parses the value: a special scheme folds `\`, the value gains a leading separator when it has none (`\a\b` and `b\..\c` both root), dot segments and a `file:` drive letter are canonicalized, and a non-hierarchical URL is left alone. + +Note the fast-path guard in the dot-segment pass: it must skip work only when the path has neither `.` nor `%`. `%2e` contains no literal dot, so a `.`-only check silently returned the unnormalized path for every percent-encoded dot segment — caught by the new unit test rather than by reasoning. + +Tests: `test-parity/node-suite/url/url/dot-segments.ts` covers the issue's own reproduction plus the trailing-slash asymmetry, empty segments, contained dots, percent-encoded spellings, non-special and opaque schemes, `file:` and Windows-drive paths, base resolution, the `pathname` setter, and `URL.parse`. `test-parity/node-suite/url/url/backslash-and-separators.ts` covers the special-scheme shape: backslash folding and authority termination, the skipped separator run, the optional `//`, the `file:` forms (including its host canonicalization), the `|` drive spelling, invalid non-special hosts, `URL.canParse`, and the setter for all three shapes. Unit coverage in `crates/perry-runtime/src/url/mod.rs` pins the path rules, the parser's classification, and the validator table directly. + +Validated byte-for-byte against the pinned Node oracle: the issue's command prints `0 /a/c /c`, both new cases PASS through `run_parity_tests.sh`, and the whole `test-parity/node-suite/url` module is 69/69 PASS with no previously-passing case changed. No version or dependency changes. diff --git a/crates/perry-runtime/src/url/mod.rs b/crates/perry-runtime/src/url/mod.rs index 21fd4f04756..ec7e3a1717f 100644 --- a/crates/perry-runtime/src/url/mod.rs +++ b/crates/perry-runtime/src/url/mod.rs @@ -242,7 +242,9 @@ pub(crate) fn is_ipv4_host(host: &str) -> bool { #[cfg(test)] mod tests { - use super::parse::{parse_url, resolve_url}; + use super::parse::{ + is_valid_absolute_url, normalize_hierarchical_path, parse_url, resolve_url, + }; use super::search_params::{ create_url_search_params_object, get_url_search_params_entries, parse_query_string, }; @@ -382,6 +384,204 @@ mod tests { ); } + #[test] + fn parse_url_removes_dot_segments() { + // #12864: WHATWG removes `.`/`..` path segments during parsing, so the + // constructor's `pathname` matches Node. Opaque paths keep theirs. + for (input, expected) in [ + ("https://user:pw@example.com:8443/a/b/../c?q=1#f", "/a/c"), + ("https://x/a/./b/../../c", "/c"), + ("https://x/a/..", "/"), + ("https://x/a/.", "/a/"), + ("foo://a/b/../c", "/c"), + ("file:///a/b/../c", "/a/c"), + ("file:///C:/..", "/C:/"), + ("mailto:a/../b", "a/../b"), + ] { + let (_, _, _, _, pathname, _, _) = parse_url(input); + assert_eq!(pathname, expected, "pathname of {input}"); + } + } + + #[test] + fn parse_url_folds_backslashes_for_special_schemes() { + // #12864: WHATWG treats `\` as a path separator for special schemes and + // as the authority terminator, and the "ignore slashes" state skips a + // whole run of them. Query and fragment keep theirs literally. + for (input, expected_host, expected_pathname) in [ + ("http://x\\a\\b", "x", "/a/b"), + ("http://x/a\\b\\..\\c", "x", "/a/c"), + ("ws://x/a\\b", "x", "/a/b"), + ("http://\\y", "y", "/"), + ("https:///x", "x", "/"), + ("https:////x", "x", "/"), + ("http://x\\", "x", "/"), + ("https://x/a\\..\\b", "x", "/b"), + ("file:\\x", "", "/x"), + ("file:\\\\x", "x", "/"), + ("file:////x", "", "//x"), + ("file:///a\\b\\..\\c", "", "/a/c"), + ] { + let (_, parsed_host, _, _, parsed_pathname, _, _) = parse_url(input); + assert_eq!( + (parsed_host.as_str(), parsed_pathname.as_str()), + (expected_host, expected_pathname), + "{input}" + ); + } + + // A non-special path is not a special one: the backslash is literal. + let (_, _, _, _, pathname, _, _) = parse_url("foo://a/b\\c"); + assert_eq!(pathname, "/b\\c"); + + // The query and fragment encode sets do not encode `\`. + let (_, _, _, _, pathname, search, hash) = parse_url("https://x/a\\b?q=\\a#f\\b"); + assert_eq!(pathname, "/a/b"); + assert_eq!(search, "?q=\\a"); + assert_eq!(hash, "#f\\b"); + + // `\` is a forbidden host code point, so a non-special authority that + // contains one is not a URL at all. + assert!(!is_valid_absolute_url("foo://a\\b")); + assert!(is_valid_absolute_url("foo://a/b\\c")); + // ... while a special one folds it away before the host is read. + assert!(is_valid_absolute_url("http://\\y")); + assert!(!is_valid_absolute_url("http://\\\\")); + } + + #[test] + fn path_normalization_canonicalizes_a_file_windows_drive_letter() { + // WHATWG rewrites the drive letter's second code point to `:`, accepts + // the `|` spelling, and then refuses to shorten that lone drive. + for (input, expected) in [ + ("/C|/x", "/C:/x"), + ("/z|/x", "/z:/x"), + ("/C|", "/C:"), + ("/C|/..", "/C:/"), + ("/C|/x/../y", "/C:/y"), + ("file", "file"), + // Only a two-character drive shape is a drive letter. + ("/ab|/x", "/ab|/x"), + ] { + assert_eq!( + normalize_hierarchical_path(input, true), + expected, + "{input}" + ); + } + // The rule is `file:`-only. + assert_eq!(normalize_hierarchical_path("/C|/x", false), "/C|/x"); + } + + #[test] + fn normalize_hierarchical_path_matches_the_spec_path_state() { + for (input, expected) in [ + ("/a/b/../c", "/a/c"), + ("/a/./b/../../c", "/c"), + ("/a/..", "/"), + ("/a/.", "/a/"), + ("/a/../", "/"), + ("/a/./", "/a/"), + ("/..", "/"), + ("/./", "/"), + ("/", "/"), + // Empty segments survive verbatim. + ("/a//b", "/a//b"), + ("//a/../b", "//b"), + // A segment that merely contains a dot is not a dot segment. + ("/a/.../b", "/a/.../b"), + // Percent-encoded spellings count (ASCII case-insensitive). + ("/a/%2e%2e/b", "/b"), + ("/a/%2E./b", "/b"), + ("/a/./%2e/b", "/a/b"), + ("/a/b/%2e%2e/%2e%2e/%2e%2e/c", "/c"), + // A path that cannot be hierarchical is left alone. + ("", ""), + ("a/../b", "a/../b"), + ] { + assert_eq!( + normalize_hierarchical_path(input, false), + expected, + "{input}" + ); + } + } + + #[test] + fn normalize_hierarchical_path_keeps_a_file_windows_drive_letter() { + // WHATWG's one scheme-dependent rule: `shorten` refuses to pop a + // `file:` path whose only segment is a normalized Windows drive letter. + for (input, expected) in [ + ("/C:/..", "/C:/"), + ("/C:/a/..", "/C:/"), + ("/C:/a/../..", "/C:/"), + ("/C:/./a", "/C:/a"), + // Without the drive-letter shape the ordinary rule applies. + ("/a/..", "/"), + ] { + assert_eq!( + normalize_hierarchical_path(input, true), + expected, + "{input}" + ); + } + } + + #[test] + fn special_schemes_are_hierarchical_without_a_double_slash() { + // WHATWG makes the `//` optional for special schemes (`https:x` and + // `https:/x` both host `x`), so they are never an opaque path. `file:` + // additionally accepts an empty host. #12864 + for (input, expected_host, expected_pathname) in [ + ("https:x", "x", "/"), + ("https:/x", "x", "/"), + ("https:\\x", "x", "/"), + ("ws:x", "x", "/"), + ("ftp:x/y", "x", "/y"), + ("file:", "", "/"), + ("file:x", "", "/x"), + ("file:/x", "", "/x"), + ("file://", "", "/"), + ] { + let (_, parsed_host, _, _, parsed_pathname, _, _) = parse_url(input); + assert_eq!( + (parsed_host.as_str(), parsed_pathname.as_str()), + (expected_host, expected_pathname), + "{input}" + ); + } + // A non-special scheme with nothing after `:` keeps an empty opaque path. + let (protocol, host, _, _, pathname, _, _) = parse_url("mailto:"); + assert_eq!( + (protocol.as_str(), host.as_str(), pathname.as_str()), + ("mailto:", "", "") + ); + } + + #[test] + fn is_valid_absolute_url_follows_the_special_scheme_shape() { + for (input, expected) in [ + ("https:x", true), + ("https:/x", true), + ("https:\\x", true), + ("http://\\y", true), + ("http:", false), + ("http:/", false), + ("http:\\\\", false), + ("file:", true), + ("file:x", true), + ("file:/x", true), + ("file://", true), + ("file:\\\\x", true), + ("mailto:", true), + ("foo://a\\b", false), + ("foo://a/b\\c", true), + ("", false), + ] { + assert_eq!(is_valid_absolute_url(input), expected, "{input}"); + } + } + #[test] fn test_parse_query_string() { let entries = parse_query_string("foo=bar&baz=qux"); diff --git a/crates/perry-runtime/src/url/parse.rs b/crates/perry-runtime/src/url/parse.rs index b4cc9a8cb8b..1197d122d6f 100644 --- a/crates/perry-runtime/src/url/parse.rs +++ b/crates/perry-runtime/src/url/parse.rs @@ -4,6 +4,19 @@ use super::*; use super::search_params::{create_url_search_params_object, parse_query_string}; +/// WHATWG "special scheme": the ones with a host and a slash-separated path +/// list, whose parser also treats `\` as a path separator and as an authority +/// terminator. Everything else (`foo:`, `mailto:`, `data:`, `urn:`) is +/// non-special. +pub(crate) fn is_special_url_scheme(protocol: &str) -> bool { + is_special_scheme_name(protocol.strip_suffix(':').unwrap_or(protocol)) +} + +/// The same test for a scheme name without its trailing `:`. +fn is_special_scheme_name(scheme: &str) -> bool { + matches!(scheme, "ftp" | "file" | "http" | "https" | "ws" | "wss") +} + /// Simple URL parser /// Returns (protocol, host, hostname, port, pathname, search, hash) pub(crate) fn parse_url(url_str: &str) -> (String, String, String, String, String, String, String) { @@ -39,6 +52,10 @@ pub(crate) fn parse_url(url_str: &str) -> (String, String, String, String, Strin // file branch below can split out a UNC-style host (`file://host/path`) // from a hostless path (`file:///path`, `file:/path`). #2975 let mut file_had_authority = false; + // Whether a `://` separator was consumed. The `file:`-only spelling has to + // look for its own authority marker *after* the backslash folding below, + // because `file:\host` carries one written with backslashes (#12864). + let mut scheme_slashes_consumed = false; // Extract protocol. WHATWG canonicalization lowercases the scheme, so // `HTTP://...` parses identically to `http://...` (and default-port @@ -46,6 +63,7 @@ pub(crate) fn parse_url(url_str: &str) -> (String, String, String, String, Strin if let Some(proto_idx) = remaining.find("://") { protocol = format!("{}:", remaining[..proto_idx].to_ascii_lowercase()); file_had_authority = protocol == "file:"; + scheme_slashes_consumed = true; remaining = &remaining[proto_idx + 3..]; } else if remaining.starts_with("file:") { protocol = "file:".to_string(); @@ -56,9 +74,11 @@ pub(crate) fn parse_url(url_str: &str) -> (String, String, String, String, Strin remaining = remaining.strip_prefix("//").unwrap_or(remaining); } } else if let Some(colon_idx) = remaining.find(':') { - // Non-special opaque scheme: `mailto:`, `data:`, `urn:`, etc. The - // characters before the colon must look like a scheme. The whole - // remainder is the opaque pathname; host/hostname/port stay empty. + // A scheme prefix. `mailto:`, `data:`, `urn:` and friends are opaque: + // the whole remainder is the pathname and host/hostname/port stay + // empty. A special scheme is ALWAYS hierarchical, though, and WHATWG + // makes its `//` optional (`https:x` hosts `x`, `https:/x` too), so + // those fall through to the hierarchical branch below. let scheme = &remaining[..colon_idx]; let scheme_ok = !scheme.is_empty() && scheme @@ -70,19 +90,50 @@ pub(crate) fn parse_url(url_str: &str) -> (String, String, String, String, Strin .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.'); if scheme_ok { protocol = format!("{}:", scheme.to_ascii_lowercase()); - pathname = remaining[colon_idx + 1..].to_string(); - return ( - protocol, - String::new(), - String::new(), - String::new(), - pathname, - search, - hash, - ); + if is_special_url_scheme(&protocol) { + remaining = &remaining[colon_idx + 1..]; + } else { + pathname = remaining[colon_idx + 1..].to_string(); + return ( + protocol, + String::new(), + String::new(), + String::new(), + pathname, + search, + hash, + ); + } } } + // WHATWG treats `\` as a path separator for special schemes, and the + // standard's "special authority ignore slashes" state skips a whole run of + // `/` (or `\`) after the scheme — so `http://x\a\b` is `http://x/a/b` and + // `https:///x` keeps host `x` instead of reading an empty authority. + // Folding happens after hash/search were split off above because the query + // and fragment encode sets do NOT encode `\`: `?q=\a` keeps its backslash. + // A non-special URL path keeps it too (`foo://a/b\c` is `/b\c`). #12864 + let backslash_folded; + let special = is_special_url_scheme(&protocol); + let mut remaining = if special && remaining.contains('\\') { + backslash_folded = remaining.replace('\\', "/"); + backslash_folded.as_str() + } else { + remaining + }; + // `file:` has its own slash states: extra separators are path, not + // authority (`file:////x` is an empty host with the path `//x`), so only + // non-file special schemes skip the run. + if special && protocol != "file:" { + remaining = remaining.trim_start_matches('/'); + } + // `file:\host` only shows its authority marker after the folding above. + if protocol == "file:" && !scheme_slashes_consumed && remaining.starts_with("//") { + file_had_authority = true; + remaining = remaining.strip_prefix("//").unwrap_or(remaining); + } + // For file: URLs, the rest is the pathname — unless an authority was // present and its first segment is a non-empty host (`file://host/path`, // i.e. a UNC share). `file:///path` / `file:/path` are hostless. #2975 @@ -188,9 +239,129 @@ pub(crate) fn parse_url(url_str: &str) -> (String, String, String, String, Strin } } + // WHATWG removes dot segments while it reads the path ("path state"), so + // the serialized pathname never carries them: `/a/b/../c` → `/a/c`. + // Perry stored the raw path, which is what #12864 reported. Opaque + // schemes (`mailto:`, `data:`) already returned above and keep theirs. + let pathname = normalize_hierarchical_path(&pathname, protocol == "file:"); + (protocol, host, hostname, port, pathname, search, hash) } +/// Apply the two path-shape rules the WHATWG "path state" contributes to a +/// path string: the `file:` Windows-drive canonicalization and dot-segment +/// removal. The standard interleaves them while reading; here they run in the +/// same order over the finished text. +/// +/// `file_scheme` enables both scheme-dependent rules — the drive letter +/// canonicalization (`file:///C|/x` → `/C:/x`) and the refusal to shorten a +/// path that is only a drive letter (`file:///C:/..` → `/C:/`). +pub(crate) fn normalize_hierarchical_path(path: &str, file_scheme: bool) -> String { + let drive_normalized; + let path = if file_scheme { + match normalize_file_drive_letter(path) { + Some(normalized) => { + drive_normalized = normalized; + drive_normalized.as_str() + } + None => path, + } + } else { + path + }; + remove_dot_segments(path, file_scheme) +} + +/// WHATWG: while a `file:` path is being built, a Windows drive letter keeps +/// its drive meaning — its second code point becomes `:` — and a `|` spelling +/// is accepted for it, so `file:///C|/x` serializes as `/C:/x`. Only the FIRST +/// segment sits in that position, and the two-character drive shape is what +/// distinguishes it from an ordinary segment that merely ends in `|` +/// (`/ab|/x` keeps its text). +fn normalize_file_drive_letter(path: &str) -> Option { + let rest = path.strip_prefix('/')?; + let (first, tail) = match rest.find('/') { + Some(index) => (&rest[..index], &rest[index..]), + None => (rest, ""), + }; + let bytes = first.as_bytes(); + if bytes.len() == 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b'|' { + Some(format!("/{}:{}", &first[..1], tail)) + } else { + None + } +} + +/// WHATWG dot-segment removal for one hierarchical (slash-separated, leading +/// `/`) URL path — the effect of the standard's "path state", applied to a +/// path string instead of as it is read. +/// +/// The spec shortens the segment list for every *double-dot path segment* and +/// drops every *single-dot path segment*, but appends an empty segment when the +/// dot segment is the last thing in the path (no following separator). That +/// asymmetry is why `/a/..` serializes as `/` while `/a/b/..` serializes as +/// `/a/`, and it is why this cannot be a plain `Vec::pop` loop. Everything else +/// the spec says is preserved verbatim, including empty segments from a `//` +/// run (`//a/../b` → `//b`) and any segment that merely contains a dot +/// (`/a/.../b` stays). +/// +/// `file_scheme` enables the one scheme-dependent rule: a `file:` path whose +/// whole path is a single normalized Windows drive letter is never shortened, +/// so `file:///C:/..` stays `/C:/` instead of collapsing to `/`. +pub(crate) fn remove_dot_segments(path: &str, file_scheme: bool) -> String { + // A non-hierarchical path has no segment separators to work with, and a + // path with neither `.` nor `%` cannot spell a dot segment (`%2e` carries + // no literal dot): the common case returns the borrowed text untouched. + if !path.starts_with('/') || !path.contains(['.', '%']) { + return path.to_string(); + } + + let parts: Vec<&str> = path[1..].split('/').collect(); + let final_index = parts.len() - 1; + let mut segments: Vec<&str> = Vec::with_capacity(parts.len()); + for (index, part) in parts.iter().enumerate() { + let is_final = index == final_index; + if is_double_dot_segment(part) { + let keeps_windows_drive = file_scheme + && segments.len() == 1 + && is_normalized_windows_drive_letter(segments[0]); + if !keeps_windows_drive { + segments.pop(); + } + if is_final { + segments.push(""); + } + } else if is_single_dot_segment(part) { + if is_final { + segments.push(""); + } + } else { + segments.push(part); + } + } + format!("/{}", segments.join("/")) +} + +/// WHATWG "single-dot path segment": `.` or its percent-encoded spelling +/// `%2e` (the standard compares ASCII case-insensitively). +fn is_single_dot_segment(segment: &str) -> bool { + segment == "." || segment.eq_ignore_ascii_case("%2e") +} + +/// WHATWG "double-dot path segment": `..`, `.%2e`, `%2e.` or `%2e%2e`. +fn is_double_dot_segment(segment: &str) -> bool { + segment == ".." + || segment.eq_ignore_ascii_case(".%2e") + || segment.eq_ignore_ascii_case("%2e.") + || segment.eq_ignore_ascii_case("%2e%2e") +} + +/// WHATWG "normalized Windows drive letter": an ASCII alpha followed by `:`. +fn is_normalized_windows_drive_letter(segment: &str) -> bool { + let bytes = segment.as_bytes(); + bytes.len() == 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' +} + /// Resolve a relative URL against a base URL pub(crate) fn resolve_url(url_str: &str, base_str: &str) -> String { // If url_str is already absolute, return it @@ -360,11 +531,10 @@ pub(crate) fn create_url_object(url_string: &str) -> *mut ObjectHeader { // IPv4-shorthand hosts also canonicalize to dotted-quad. We reuse the // `url`-crate-backed `whatwg_canonicalize_host` (reparses `http:///`), // which is the same path the hostname setter uses. Only special hierarchical - // schemes get host canonicalization; opaque schemes have no host. - let is_special = matches!( - protocol.as_str(), - "http:" | "https:" | "ws:" | "wss:" | "ftp:" - ); + // schemes get host canonicalization; opaque schemes have no host. `file:` + // is one of them for this purpose too: its host is a regular WHATWG host + // (`file://SERVER/x` → `server`, `file://123/x` → `0.0.0.123`). + let is_special = is_special_url_scheme(&protocol); if is_special && !hostname.is_empty() { if let Some(canon) = super::whatwg_canonicalize_host(&hostname) { hostname = canon.clone(); @@ -601,25 +771,49 @@ pub(crate) fn is_valid_absolute_url(s: &str) -> bool { if scheme_end >= s.len() || s.as_bytes()[scheme_end] != b':' { return false; } - // For hierarchical schemes (http, https, ws, wss, ftp, file) WHATWG - // requires `//` before the authority. For others (mailto:, data:, etc.) - // Node accepts anything non-empty after `:`. Match that for ergonomics. + // For hierarchical schemes the WHATWG parser requires a host for every + // special scheme except `file:`, and it accepts the `//` as optional + // (`https:x` and `https:/x` both host `x`). A `\` is a separator, never + // part of the authority, so validation reads the same folded text the + // parser does. For every other scheme Node accepts whatever follows `:`, + // empty included, and non-empty input is an opaque path. let after_scheme = &s[scheme_end + 1..]; let scheme = &s[..scheme_end]; - let needs_authority = matches!(scheme, "http" | "https" | "ws" | "wss" | "ftp" | "file"); - if needs_authority { - if !after_scheme.starts_with("//") { - return false; + if is_special_scheme_name(scheme) { + let folded; + let after_scheme = if after_scheme.contains('\\') { + folded = after_scheme.replace('\\', "/"); + folded.as_str() + } else { + after_scheme + }; + if scheme == "file" { + // The authority exists only in the `//` spelling; `file:`, `file:x` + // and `file:////x` are hostless paths with nothing to validate. + if let Some(authority) = after_scheme.strip_prefix("//") { + let authority = authority.split(['/', '?', '#']).next().unwrap_or_default(); + if !is_valid_url_authority(scheme, authority) { + return false; + } + } + } else { + let rest = after_scheme.trim_start_matches('/'); + let authority = rest.split(['/', '?', '#']).next().unwrap_or_default(); + if !is_valid_url_authority(scheme, authority) { + return false; + } } - let authority = after_scheme[2..] - .split(['/', '?', '#']) - .next() - .unwrap_or_default(); - if !is_valid_url_authority(scheme, authority) { + } else if let Some(authority) = after_scheme.strip_prefix("//") { + // A non-special `//` authority is a host too, and `\` is a forbidden + // host code point: `foo://a\b` is invalid in Node. Its path form + // (`foo://a/b\c`) stays valid — a path has no such restriction. + let authority = authority.split(['/', '?', '#']).next().unwrap_or_default(); + let host = authority + .rsplit_once('@') + .map_or(authority, |(_, host)| host); + if host.contains('\\') { return false; } - } else if after_scheme.is_empty() { - return false; } true } @@ -646,7 +840,13 @@ pub(super) fn normalize_file_drive_authority(input: &str) -> String { .unwrap_or(authority_and_path.len()); let authority = &authority_and_path[..drive_end]; let drive_bytes = authority.as_bytes(); - if drive_bytes.len() != 2 || !drive_bytes[0].is_ascii_alphabetic() || drive_bytes[1] != b':' { + // A `|` spelling is a Windows drive letter too (`file://C|/x`), so it takes + // the same drive-authority path and lets the path-level rule canonicalize + // the separators (`file:///C:/x`) (#12864). + if drive_bytes.len() != 2 + || !drive_bytes[0].is_ascii_alphabetic() + || !matches!(drive_bytes[1], b':' | b'|') + { return input.to_string(); } diff --git a/crates/perry-runtime/src/url/url_class.rs b/crates/perry-runtime/src/url/url_class.rs index 08d43d167cf..40d5d47f655 100644 --- a/crates/perry-runtime/src/url/url_class.rs +++ b/crates/perry-runtime/src/url/url_class.rs @@ -3,10 +3,10 @@ use super::*; use super::parse::{ - create_url_object, is_valid_absolute_url, rebuild_url_host, rebuild_url_href, resolve_url, - throw_invalid_url, URL_FIELD_COUNT, URL_HASH, URL_HOST, URL_HOSTNAME, URL_HREF, URL_ORIGIN, - URL_PASSWORD, URL_PATHNAME, URL_PORT, URL_PROTOCOL, URL_SEARCH, URL_SEARCH_PARAMS, - URL_USERNAME, + create_url_object, is_special_url_scheme, is_valid_absolute_url, normalize_hierarchical_path, + rebuild_url_host, rebuild_url_href, resolve_url, throw_invalid_url, URL_FIELD_COUNT, URL_HASH, + URL_HOST, URL_HOSTNAME, URL_HREF, URL_ORIGIN, URL_PASSWORD, URL_PATHNAME, URL_PORT, + URL_PROTOCOL, URL_SEARCH, URL_SEARCH_PARAMS, URL_USERNAME, }; use super::search_params::{ create_url_search_params_object, parse_query_string, URL_SEARCH_PARAMS_OWNER, @@ -73,13 +73,6 @@ fn should_strip_url_ascii_whitespace(b: u8) -> bool { matches!(b, b'\t' | b'\n' | b'\r') } -fn is_special_url_scheme(protocol: &str) -> bool { - matches!( - protocol, - "ftp:" | "file:" | "http:" | "https:" | "ws:" | "wss:" - ) -} - fn normalize_protocol_value(raw: &str) -> Option { let scheme = raw.strip_suffix(':').unwrap_or(raw); let mut chars = scheme.chars(); @@ -258,12 +251,14 @@ pub extern "C" fn js_url_get_href(url: *mut ObjectHeader) -> f64 { crate::object::js_object_get_field_f64(url, URL_HREF) } -/// Issue #650: `url.pathname = value` setter. Per WHATWG, the setter -/// normalizes a leading `/` for hierarchical schemes — but Node's actual -/// runtime behavior on `u.pathname = "changed"` produces pathname -/// `"/changed"` ONLY when the URL has an authority component, leaving -/// opaque (non-hierarchical) URLs alone. We follow Node: prepend `/` -/// when the URL has a non-empty `host`. +/// Issue #650: `url.pathname = value` setter. Per WHATWG the assignment clears +/// the URL's path and re-runs the parser's path states over the new value, so +/// everything construction does to a path applies here too: a `\` is a segment +/// separator for a special scheme, the value gains a leading separator when it +/// has none, and dot segments (and a `file:` Windows drive letter) are +/// canonicalized (#12864). Non-hierarchical URLs keep their opaque path, and a +/// non-special one keeps backslashes literally — `foo://x` + `/b\c` is +/// `/b\c`, matching Node. #[no_mangle] pub extern "C" fn js_url_set_pathname(url: *mut ObjectHeader, value: f64) { if url.is_null() { @@ -272,12 +267,24 @@ pub extern "C" fn js_url_set_pathname(url: *mut ObjectHeader, value: f64) { let raw = coerce_url_setter_value(value); unsafe { let host = get_string_content(crate::object::js_object_get_field_f64(url, URL_HOST)); - let normalized = if !host.is_empty() && !raw.starts_with('/') { + let protocol = + get_string_content(crate::object::js_object_get_field_f64(url, URL_PROTOCOL)); + let special = is_special_url_scheme(&protocol); + let hierarchical = !host.is_empty() || protocol == "file:"; + // Fold before the leading-separator test: for a special URL `\a` is + // already a rooted path, so it must not gain a second separator. + let raw = if special { raw.replace('\\', "/") } else { raw }; + let normalized = if hierarchical && !raw.starts_with('/') { format!("/{}", raw) } else { raw }; let encoded = percent_encode_path(&normalized); + let encoded = if hierarchical { + normalize_hierarchical_path(&encoded, protocol == "file:") + } else { + encoded + }; js_object_set_field_f64(url, URL_PATHNAME, create_string_f64(&encoded)); rebuild_url_href(url); } diff --git a/test-parity/node-suite/url/url/backslash-and-separators.ts b/test-parity/node-suite/url/url/backslash-and-separators.ts new file mode 100644 index 00000000000..52bcd1b4d0a --- /dev/null +++ b/test-parity/node-suite/url/url/backslash-and-separators.ts @@ -0,0 +1,127 @@ +// #12864 follow-up: the WHATWG shape of a *special* URL besides dot segments. +// For `http:`/`https:`/`ws:`/`wss:`/`ftp:`/`file:`, `\` is a path separator and +// an authority terminator, the `//` is optional, and a run of separators after +// the scheme is skipped. `file:` is the special one: extra separators are path, +// its host may be empty, and a lone Windows drive letter keeps its `:` (and +// accepts the `|` spelling). A non-special URL keeps backslashes literally, and +// a backslash in a non-special *host* makes the URL invalid. +// +// Compared byte-for-byte against `node --experimental-strip-types`. + +function show(label: string, u: URL) { + console.log(JSON.stringify([label, u.host, u.pathname, u.search, u.hash, u.href])); +} + +function attempt(input: string) { + try { + show(input, new URL(input)); + } catch (e: any) { + console.log(JSON.stringify([input, "THROW", e.code])); + } +} + +// `\` as the authority terminator and path separator, plus the skipped run. +for (const input of [ + "http://x\\a\\b", + "http://x/a\\b\\..\\c", + "http://x/a\\..\\b", + "http://x\\a", + "http://\\y", + "http://\\\\y", + "https:///x", + "https:////x", + "ws:////x", + "http://x//y", + "http://x\\", + "ftp:x/y", +]) { + attempt(input); +} + +// The query and fragment encode sets do not encode `\`, so those keep theirs +// while the path folds. +attempt("https://x\\y\\..\\z?q=\\a#f\\b"); +attempt("https://x/?q=\\a"); +attempt("https://x/#f\\b"); + +// A special scheme is hierarchical even without the `//`; a non-special one is +// an opaque path. Empty hosts are only allowed for `file:`. +for (const input of ["https:x", "https:/x", "https:\\x", "https:", "http:/", "http:\\\\", "file:"]) { + attempt(input); +} +attempt("mailto:"); +attempt("data:text/plain,a/../b"); + +// `file:` separators: `file:\x` is a hostless path, `file:\\x` carries a +// host, and extra separators belong to the path. Its host is an ordinary +// WHATWG host, so it canonicalizes like any other special scheme. +for (const input of [ + "file:\\x", + "file:\\\\x", + "file:\\\\\\\\x", + "file:/x", + "file:x", + "file://", + "file:////x", + "file://server\\share\\x", + "file://SERVER/x", + "file://123/x", + "file://LOCALHOST/x", + "file:///a\\b\\..\\c", +]) { + attempt(input); +} + +// The Windows drive letter: `|` is accepted for `:`, only in the first path +// segment, only for `file:`, and that lone drive is never shortened. +for (const input of [ + "file://C|/x", + "file:///C|/x", + "file:///z|/x", + "file:///C|", + "file:///C|/..", + "file:///C|/x/../y", + "file:///ab|/x", +]) { + attempt(input); +} + +// Non-special schemes: the path keeps backslashes, the host may not have one. +for (const input of ["foo://a/b\\c", "foo://a\\b"]) { + attempt(input); +} + +// Validation follows the same shape. +for (const input of [ + "https:x", + "https:/x", + "http://\\y", + "http://\\\\", + "foo://a\\b", + "foo://a/b\\c", + "file:\\x", + "file:////x", + "mailto:", + "https:", +]) { + console.log(JSON.stringify(["canParse", input, URL.canParse(input)])); +} + +// Assigning `pathname` re-runs the same path states. +const https = new URL("https://x/a"); +for (const value of ["/b\\c", "\\a\\b", "b\\..\\c", "//a/../b"]) { + https.pathname = value; + show(`set https ${value}`, https); +} + +const file = new URL("file:///a/b"); +for (const value of ["/C|/x", "/x\\..\\y", "abc", ""]) { + file.pathname = value; + show(`set file ${value}`, file); +} + +const nonSpecial = new URL("foo://x/a"); +for (const value of ["/b\\c"]) { + nonSpecial.pathname = value; + show(`set foo ${value}`, nonSpecial); +} diff --git a/test-parity/node-suite/url/url/dot-segments.ts b/test-parity/node-suite/url/url/dot-segments.ts new file mode 100644 index 00000000000..f76da1a7081 --- /dev/null +++ b/test-parity/node-suite/url/url/dot-segments.ts @@ -0,0 +1,91 @@ +// #12864: WHATWG removes `.` and `..` path segments, so `pathname` (and the +// `href` derived from it) never carries them. Every case below is compared +// byte-for-byte against `node --experimental-strip-types`. +// +// The asymmetry is the interesting part: a dot segment is dropped, but when it +// is the LAST thing in the path the parser appends an empty segment instead of +// nothing, so `/a/..` is `/` while `/a/b/..` is `/a/`. + +// The issue's own reproduction, verbatim. +const i = 0; +const u = new URL("https://user:pw@example.com:8443/a/b/../c?q=1#f"); +console.log(i, u.pathname, new URL("https://x/a/./b/../../c").pathname); + +// Special-scheme paths: plain, trailing, double-separator and contained-dot. +for (const input of [ + "https://x/a/b/../c", + "https://x/a/..", + "https://x/a/.", + "https://x/..", + "https://x/./", + "https://x/a/../", + "https://x/a/./", + "https://x/a//b", + "https://x//a/../b", + "https://x/a/.../b", + "https://x/a/b/../../..", + "https://x/a/b/c/../../d/", +]) { + const parsed = new URL(input); + console.log(JSON.stringify([input, parsed.pathname, parsed.href])); +} + +// The dot segments are recognized in their percent-encoded spellings too +// (ASCII case-insensitive); `%2e%2e%2e` is not a double-dot segment. +for (const input of [ + "https://x/a/%2e%2e/b", + "https://x/a/%2E./b", + "https://x/a/my%2e/b", + "https://x/a/./%2e/b", + "https://x/a/b/%2e%2e/%2e%2e/%2e%2e/c", + "https://x/a/%2e%2e%2e/b", +]) { + const parsed = new URL(input); + console.log(JSON.stringify([input, parsed.pathname, parsed.href])); +} + +// Non-special hierarchical schemes share the rule; an opaque path is not a +// segment list and keeps its text. +for (const input of [ + "foo://a/b/../c", + "foo://a/b/./c", + "mailto:a/../b", + "data:text/plain,a/../b", +]) { + const parsed = new URL(input); + console.log(JSON.stringify([input, parsed.pathname])); +} + +// `file:` URLs remove dot segments, except that a path which is only a Windows +// drive letter is never shortened. +for (const input of [ + "file:///a/b/../c", + "file:///a/./b/", + "file:///a/b/../../../c", + "file:///C:/..", + "file:///C:/a/../..", + "file:///C:/./a", +]) { + const parsed = new URL(input); + console.log(JSON.stringify([input, parsed.pathname, parsed.href])); +} + +// Resolution against a base goes through the same path state. +const base = new URL("https://x/a/b/"); +for (const relative of ["../c", "/a/../c", "./d", "../../e"]) { + const resolved = new URL(relative, base); + console.log(JSON.stringify([relative, resolved.pathname, resolved.href])); +} + +// Assigning `pathname` runs the same rules as construction. +const assigned = new URL("https://x/a/b"); +assigned.pathname = "/a/../z"; +console.log(JSON.stringify(["assigned", assigned.pathname, assigned.href])); +assigned.pathname = "q/./r/../s"; +console.log(JSON.stringify(["assigned-relative", assigned.pathname, assigned.href])); +assigned.pathname = "/a/%2e%2e/t"; +console.log(JSON.stringify(["assigned-encoded", assigned.pathname, assigned.href])); + +// `URL.parse` is the same constructor path. +const parsedStatic = URL.parse("https://x/a/../b"); +console.log(JSON.stringify(["URL.parse", parsedStatic!.pathname, parsedStatic!.href])); From 52fca4d200ea290521b311bebe2a057eb3b2c5a8 Mon Sep 17 00:00:00 2001 From: Sergi Gonzalez <31130069+TheHypnoo@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:46:47 +0200 Subject: [PATCH 2/3] chore(changelog): key the url fragment to its PR number --- .../{12864-url-dot-segments.md => 13195-url-dot-segments.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{12864-url-dot-segments.md => 13195-url-dot-segments.md} (100%) diff --git a/changelog.d/12864-url-dot-segments.md b/changelog.d/13195-url-dot-segments.md similarity index 100% rename from changelog.d/12864-url-dot-segments.md rename to changelog.d/13195-url-dot-segments.md From ad903679a2106d3f9d529aea104816fe0bbe49a8 Mon Sep 17 00:00:00 2001 From: Sergi Gonzalez <31130069+TheHypnoo@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:06:50 +0200 Subject: [PATCH 3/3] refactor(url): fold the path helpers and trim the commentary Drop the Rust unit tests for the path rules (the node-parity cases already cover them), merge the two path helpers into one entry point, detect the special scheme before the fold so the `scheme_slashes_consumed` flag and its post-fold authority check disappear, and keep only the comments that explain a spec quirk the code cannot show. --- crates/perry-runtime/src/url/mod.rs | 202 +--------------------- crates/perry-runtime/src/url/parse.rs | 186 +++++++------------- crates/perry-runtime/src/url/url_class.rs | 24 ++- 3 files changed, 69 insertions(+), 343 deletions(-) diff --git a/crates/perry-runtime/src/url/mod.rs b/crates/perry-runtime/src/url/mod.rs index ec7e3a1717f..21fd4f04756 100644 --- a/crates/perry-runtime/src/url/mod.rs +++ b/crates/perry-runtime/src/url/mod.rs @@ -242,9 +242,7 @@ pub(crate) fn is_ipv4_host(host: &str) -> bool { #[cfg(test)] mod tests { - use super::parse::{ - is_valid_absolute_url, normalize_hierarchical_path, parse_url, resolve_url, - }; + use super::parse::{parse_url, resolve_url}; use super::search_params::{ create_url_search_params_object, get_url_search_params_entries, parse_query_string, }; @@ -384,204 +382,6 @@ mod tests { ); } - #[test] - fn parse_url_removes_dot_segments() { - // #12864: WHATWG removes `.`/`..` path segments during parsing, so the - // constructor's `pathname` matches Node. Opaque paths keep theirs. - for (input, expected) in [ - ("https://user:pw@example.com:8443/a/b/../c?q=1#f", "/a/c"), - ("https://x/a/./b/../../c", "/c"), - ("https://x/a/..", "/"), - ("https://x/a/.", "/a/"), - ("foo://a/b/../c", "/c"), - ("file:///a/b/../c", "/a/c"), - ("file:///C:/..", "/C:/"), - ("mailto:a/../b", "a/../b"), - ] { - let (_, _, _, _, pathname, _, _) = parse_url(input); - assert_eq!(pathname, expected, "pathname of {input}"); - } - } - - #[test] - fn parse_url_folds_backslashes_for_special_schemes() { - // #12864: WHATWG treats `\` as a path separator for special schemes and - // as the authority terminator, and the "ignore slashes" state skips a - // whole run of them. Query and fragment keep theirs literally. - for (input, expected_host, expected_pathname) in [ - ("http://x\\a\\b", "x", "/a/b"), - ("http://x/a\\b\\..\\c", "x", "/a/c"), - ("ws://x/a\\b", "x", "/a/b"), - ("http://\\y", "y", "/"), - ("https:///x", "x", "/"), - ("https:////x", "x", "/"), - ("http://x\\", "x", "/"), - ("https://x/a\\..\\b", "x", "/b"), - ("file:\\x", "", "/x"), - ("file:\\\\x", "x", "/"), - ("file:////x", "", "//x"), - ("file:///a\\b\\..\\c", "", "/a/c"), - ] { - let (_, parsed_host, _, _, parsed_pathname, _, _) = parse_url(input); - assert_eq!( - (parsed_host.as_str(), parsed_pathname.as_str()), - (expected_host, expected_pathname), - "{input}" - ); - } - - // A non-special path is not a special one: the backslash is literal. - let (_, _, _, _, pathname, _, _) = parse_url("foo://a/b\\c"); - assert_eq!(pathname, "/b\\c"); - - // The query and fragment encode sets do not encode `\`. - let (_, _, _, _, pathname, search, hash) = parse_url("https://x/a\\b?q=\\a#f\\b"); - assert_eq!(pathname, "/a/b"); - assert_eq!(search, "?q=\\a"); - assert_eq!(hash, "#f\\b"); - - // `\` is a forbidden host code point, so a non-special authority that - // contains one is not a URL at all. - assert!(!is_valid_absolute_url("foo://a\\b")); - assert!(is_valid_absolute_url("foo://a/b\\c")); - // ... while a special one folds it away before the host is read. - assert!(is_valid_absolute_url("http://\\y")); - assert!(!is_valid_absolute_url("http://\\\\")); - } - - #[test] - fn path_normalization_canonicalizes_a_file_windows_drive_letter() { - // WHATWG rewrites the drive letter's second code point to `:`, accepts - // the `|` spelling, and then refuses to shorten that lone drive. - for (input, expected) in [ - ("/C|/x", "/C:/x"), - ("/z|/x", "/z:/x"), - ("/C|", "/C:"), - ("/C|/..", "/C:/"), - ("/C|/x/../y", "/C:/y"), - ("file", "file"), - // Only a two-character drive shape is a drive letter. - ("/ab|/x", "/ab|/x"), - ] { - assert_eq!( - normalize_hierarchical_path(input, true), - expected, - "{input}" - ); - } - // The rule is `file:`-only. - assert_eq!(normalize_hierarchical_path("/C|/x", false), "/C|/x"); - } - - #[test] - fn normalize_hierarchical_path_matches_the_spec_path_state() { - for (input, expected) in [ - ("/a/b/../c", "/a/c"), - ("/a/./b/../../c", "/c"), - ("/a/..", "/"), - ("/a/.", "/a/"), - ("/a/../", "/"), - ("/a/./", "/a/"), - ("/..", "/"), - ("/./", "/"), - ("/", "/"), - // Empty segments survive verbatim. - ("/a//b", "/a//b"), - ("//a/../b", "//b"), - // A segment that merely contains a dot is not a dot segment. - ("/a/.../b", "/a/.../b"), - // Percent-encoded spellings count (ASCII case-insensitive). - ("/a/%2e%2e/b", "/b"), - ("/a/%2E./b", "/b"), - ("/a/./%2e/b", "/a/b"), - ("/a/b/%2e%2e/%2e%2e/%2e%2e/c", "/c"), - // A path that cannot be hierarchical is left alone. - ("", ""), - ("a/../b", "a/../b"), - ] { - assert_eq!( - normalize_hierarchical_path(input, false), - expected, - "{input}" - ); - } - } - - #[test] - fn normalize_hierarchical_path_keeps_a_file_windows_drive_letter() { - // WHATWG's one scheme-dependent rule: `shorten` refuses to pop a - // `file:` path whose only segment is a normalized Windows drive letter. - for (input, expected) in [ - ("/C:/..", "/C:/"), - ("/C:/a/..", "/C:/"), - ("/C:/a/../..", "/C:/"), - ("/C:/./a", "/C:/a"), - // Without the drive-letter shape the ordinary rule applies. - ("/a/..", "/"), - ] { - assert_eq!( - normalize_hierarchical_path(input, true), - expected, - "{input}" - ); - } - } - - #[test] - fn special_schemes_are_hierarchical_without_a_double_slash() { - // WHATWG makes the `//` optional for special schemes (`https:x` and - // `https:/x` both host `x`), so they are never an opaque path. `file:` - // additionally accepts an empty host. #12864 - for (input, expected_host, expected_pathname) in [ - ("https:x", "x", "/"), - ("https:/x", "x", "/"), - ("https:\\x", "x", "/"), - ("ws:x", "x", "/"), - ("ftp:x/y", "x", "/y"), - ("file:", "", "/"), - ("file:x", "", "/x"), - ("file:/x", "", "/x"), - ("file://", "", "/"), - ] { - let (_, parsed_host, _, _, parsed_pathname, _, _) = parse_url(input); - assert_eq!( - (parsed_host.as_str(), parsed_pathname.as_str()), - (expected_host, expected_pathname), - "{input}" - ); - } - // A non-special scheme with nothing after `:` keeps an empty opaque path. - let (protocol, host, _, _, pathname, _, _) = parse_url("mailto:"); - assert_eq!( - (protocol.as_str(), host.as_str(), pathname.as_str()), - ("mailto:", "", "") - ); - } - - #[test] - fn is_valid_absolute_url_follows_the_special_scheme_shape() { - for (input, expected) in [ - ("https:x", true), - ("https:/x", true), - ("https:\\x", true), - ("http://\\y", true), - ("http:", false), - ("http:/", false), - ("http:\\\\", false), - ("file:", true), - ("file:x", true), - ("file:/x", true), - ("file://", true), - ("file:\\\\x", true), - ("mailto:", true), - ("foo://a\\b", false), - ("foo://a/b\\c", true), - ("", false), - ] { - assert_eq!(is_valid_absolute_url(input), expected, "{input}"); - } - } - #[test] fn test_parse_query_string() { let entries = parse_query_string("foo=bar&baz=qux"); diff --git a/crates/perry-runtime/src/url/parse.rs b/crates/perry-runtime/src/url/parse.rs index 1197d122d6f..ec794138167 100644 --- a/crates/perry-runtime/src/url/parse.rs +++ b/crates/perry-runtime/src/url/parse.rs @@ -4,17 +4,22 @@ use super::*; use super::search_params::{create_url_search_params_object, parse_query_string}; -/// WHATWG "special scheme": the ones with a host and a slash-separated path -/// list, whose parser also treats `\` as a path separator and as an authority -/// terminator. Everything else (`foo:`, `mailto:`, `data:`, `urn:`) is -/// non-special. +/// WHATWG "special scheme": has a host, and its parser treats `\` as a path +/// separator (`foo:`, `mailto:`, `data:` paths keep it). pub(crate) fn is_special_url_scheme(protocol: &str) -> bool { is_special_scheme_name(protocol.strip_suffix(':').unwrap_or(protocol)) } -/// The same test for a scheme name without its trailing `:`. fn is_special_scheme_name(scheme: &str) -> bool { - matches!(scheme, "ftp" | "file" | "http" | "https" | "ws" | "wss") + ["ftp", "file", "http", "https", "ws", "wss"] + .iter() + .any(|name| scheme.eq_ignore_ascii_case(name)) +} + +fn special_scheme_prefix(input: &str) -> bool { + input + .find(':') + .is_some_and(|colon| is_special_scheme_name(&input[..colon])) } /// Simple URL parser @@ -48,14 +53,21 @@ pub(crate) fn parse_url(url_str: &str) -> (String, String, String, String, Strin remaining = &remaining[..query_idx]; } + // A special scheme's `\` is a separator, including where the authority + // marker sits. Query and fragment keep theirs (their encode sets don't + // encode it) and were split off above, so the fold happens here. + let folded; + let mut remaining = if special_scheme_prefix(remaining) && remaining.contains('\\') { + folded = remaining.replace('\\', "/"); + folded.as_str() + } else { + remaining + }; + // Track whether the `file:` URL carried a `//` authority marker so the // file branch below can split out a UNC-style host (`file://host/path`) // from a hostless path (`file:///path`, `file:/path`). #2975 let mut file_had_authority = false; - // Whether a `://` separator was consumed. The `file:`-only spelling has to - // look for its own authority marker *after* the backslash folding below, - // because `file:\host` carries one written with backslashes (#12864). - let mut scheme_slashes_consumed = false; // Extract protocol. WHATWG canonicalization lowercases the scheme, so // `HTTP://...` parses identically to `http://...` (and default-port @@ -63,7 +75,6 @@ pub(crate) fn parse_url(url_str: &str) -> (String, String, String, String, Strin if let Some(proto_idx) = remaining.find("://") { protocol = format!("{}:", remaining[..proto_idx].to_ascii_lowercase()); file_had_authority = protocol == "file:"; - scheme_slashes_consumed = true; remaining = &remaining[proto_idx + 3..]; } else if remaining.starts_with("file:") { protocol = "file:".to_string(); @@ -74,11 +85,8 @@ pub(crate) fn parse_url(url_str: &str) -> (String, String, String, String, Strin remaining = remaining.strip_prefix("//").unwrap_or(remaining); } } else if let Some(colon_idx) = remaining.find(':') { - // A scheme prefix. `mailto:`, `data:`, `urn:` and friends are opaque: - // the whole remainder is the pathname and host/hostname/port stay - // empty. A special scheme is ALWAYS hierarchical, though, and WHATWG - // makes its `//` optional (`https:x` hosts `x`, `https:/x` too), so - // those fall through to the hierarchical branch below. + // A special scheme is always hierarchical (the `//` is optional), so + // only the other schemes are opaque. let scheme = &remaining[..colon_idx]; let scheme_ok = !scheme.is_empty() && scheme @@ -107,32 +115,12 @@ pub(crate) fn parse_url(url_str: &str) -> (String, String, String, String, Strin } } - // WHATWG treats `\` as a path separator for special schemes, and the - // standard's "special authority ignore slashes" state skips a whole run of - // `/` (or `\`) after the scheme — so `http://x\a\b` is `http://x/a/b` and - // `https:///x` keeps host `x` instead of reading an empty authority. - // Folding happens after hash/search were split off above because the query - // and fragment encode sets do NOT encode `\`: `?q=\a` keeps its backslash. - // A non-special URL path keeps it too (`foo://a/b\c` is `/b\c`). #12864 - let backslash_folded; let special = is_special_url_scheme(&protocol); - let mut remaining = if special && remaining.contains('\\') { - backslash_folded = remaining.replace('\\', "/"); - backslash_folded.as_str() - } else { - remaining - }; - // `file:` has its own slash states: extra separators are path, not - // authority (`file:////x` is an empty host with the path `//x`), so only - // non-file special schemes skip the run. if special && protocol != "file:" { + // A special scheme skips the whole separator run. `file:` keeps its + // extra separators as path (`file:////x` is the path `//x`). remaining = remaining.trim_start_matches('/'); } - // `file:\host` only shows its authority marker after the folding above. - if protocol == "file:" && !scheme_slashes_consumed && remaining.starts_with("//") { - file_had_authority = true; - remaining = remaining.strip_prefix("//").unwrap_or(remaining); - } // For file: URLs, the rest is the pathname — unless an authority was // present and its first segment is a non-empty host (`file://host/path`, @@ -239,79 +227,21 @@ pub(crate) fn parse_url(url_str: &str) -> (String, String, String, String, Strin } } - // WHATWG removes dot segments while it reads the path ("path state"), so - // the serialized pathname never carries them: `/a/b/../c` → `/a/c`. - // Perry stored the raw path, which is what #12864 reported. Opaque - // schemes (`mailto:`, `data:`) already returned above and keep theirs. - let pathname = normalize_hierarchical_path(&pathname, protocol == "file:"); + let pathname = normalize_path(&pathname, protocol == "file:"); (protocol, host, hostname, port, pathname, search, hash) } -/// Apply the two path-shape rules the WHATWG "path state" contributes to a -/// path string: the `file:` Windows-drive canonicalization and dot-segment -/// removal. The standard interleaves them while reading; here they run in the -/// same order over the finished text. -/// -/// `file_scheme` enables both scheme-dependent rules — the drive letter -/// canonicalization (`file:///C|/x` → `/C:/x`) and the refusal to shorten a -/// path that is only a drive letter (`file:///C:/..` → `/C:/`). -pub(crate) fn normalize_hierarchical_path(path: &str, file_scheme: bool) -> String { - let drive_normalized; - let path = if file_scheme { - match normalize_file_drive_letter(path) { - Some(normalized) => { - drive_normalized = normalized; - drive_normalized.as_str() - } - None => path, - } - } else { - path - }; - remove_dot_segments(path, file_scheme) -} - -/// WHATWG: while a `file:` path is being built, a Windows drive letter keeps -/// its drive meaning — its second code point becomes `:` — and a `|` spelling -/// is accepted for it, so `file:///C|/x` serializes as `/C:/x`. Only the FIRST -/// segment sits in that position, and the two-character drive shape is what -/// distinguishes it from an ordinary segment that merely ends in `|` -/// (`/ab|/x` keeps its text). -fn normalize_file_drive_letter(path: &str) -> Option { - let rest = path.strip_prefix('/')?; - let (first, tail) = match rest.find('/') { - Some(index) => (&rest[..index], &rest[index..]), - None => (rest, ""), - }; - let bytes = first.as_bytes(); - if bytes.len() == 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b'|' { - Some(format!("/{}:{}", &first[..1], tail)) +/// Apply the WHATWG path state to a hierarchical path: the `file:` drive-letter +/// canonicalization, then dot-segment removal. +pub(crate) fn normalize_path(path: &str, file_scheme: bool) -> String { + let drive = if file_scheme { + normalize_file_drive_letter(path) } else { None - } -} - -/// WHATWG dot-segment removal for one hierarchical (slash-separated, leading -/// `/`) URL path — the effect of the standard's "path state", applied to a -/// path string instead of as it is read. -/// -/// The spec shortens the segment list for every *double-dot path segment* and -/// drops every *single-dot path segment*, but appends an empty segment when the -/// dot segment is the last thing in the path (no following separator). That -/// asymmetry is why `/a/..` serializes as `/` while `/a/b/..` serializes as -/// `/a/`, and it is why this cannot be a plain `Vec::pop` loop. Everything else -/// the spec says is preserved verbatim, including empty segments from a `//` -/// run (`//a/../b` → `//b`) and any segment that merely contains a dot -/// (`/a/.../b` stays). -/// -/// `file_scheme` enables the one scheme-dependent rule: a `file:` path whose -/// whole path is a single normalized Windows drive letter is never shortened, -/// so `file:///C:/..` stays `/C:/` instead of collapsing to `/`. -pub(crate) fn remove_dot_segments(path: &str, file_scheme: bool) -> String { - // A non-hierarchical path has no segment separators to work with, and a - // path with neither `.` nor `%` cannot spell a dot segment (`%2e` carries - // no literal dot): the common case returns the borrowed text untouched. + }; + let path = drive.as_deref().unwrap_or(path); + // No `.` and no `%` means no dot segment: `%2e` has no literal dot. if !path.starts_with('/') || !path.contains(['.', '%']) { return path.to_string(); } @@ -322,12 +252,14 @@ pub(crate) fn remove_dot_segments(path: &str, file_scheme: bool) -> String { for (index, part) in parts.iter().enumerate() { let is_final = index == final_index; if is_double_dot_segment(part) { - let keeps_windows_drive = file_scheme + let keeps_drive = file_scheme && segments.len() == 1 && is_normalized_windows_drive_letter(segments[0]); - if !keeps_windows_drive { + if !keeps_drive { segments.pop(); } + // A trailing dot segment leaves an empty segment behind: `/a/..` + // is `/` while `/a/b/..` is `/a/`. if is_final { segments.push(""); } @@ -342,13 +274,24 @@ pub(crate) fn remove_dot_segments(path: &str, file_scheme: bool) -> String { format!("/{}", segments.join("/")) } -/// WHATWG "single-dot path segment": `.` or its percent-encoded spelling -/// `%2e` (the standard compares ASCII case-insensitively). +fn normalize_file_drive_letter(path: &str) -> Option { + let rest = path.strip_prefix('/')?; + let (first, tail) = match rest.find('/') { + Some(index) => (&rest[..index], &rest[index..]), + None => (rest, ""), + }; + let bytes = first.as_bytes(); + if bytes.len() == 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b'|' { + Some(format!("/{}:{}", &first[..1], tail)) + } else { + None + } +} + fn is_single_dot_segment(segment: &str) -> bool { segment == "." || segment.eq_ignore_ascii_case("%2e") } -/// WHATWG "double-dot path segment": `..`, `.%2e`, `%2e.` or `%2e%2e`. fn is_double_dot_segment(segment: &str) -> bool { segment == ".." || segment.eq_ignore_ascii_case(".%2e") @@ -356,7 +299,6 @@ fn is_double_dot_segment(segment: &str) -> bool { || segment.eq_ignore_ascii_case("%2e%2e") } -/// WHATWG "normalized Windows drive letter": an ASCII alpha followed by `:`. fn is_normalized_windows_drive_letter(segment: &str) -> bool { let bytes = segment.as_bytes(); bytes.len() == 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' @@ -531,9 +473,8 @@ pub(crate) fn create_url_object(url_string: &str) -> *mut ObjectHeader { // IPv4-shorthand hosts also canonicalize to dotted-quad. We reuse the // `url`-crate-backed `whatwg_canonicalize_host` (reparses `http:///`), // which is the same path the hostname setter uses. Only special hierarchical - // schemes get host canonicalization; opaque schemes have no host. `file:` - // is one of them for this purpose too: its host is a regular WHATWG host - // (`file://SERVER/x` → `server`, `file://123/x` → `0.0.0.123`). + // schemes get host canonicalization; opaque schemes have no host, and + // `file:` is one of them for this purpose too (`file://SERVER/x` → `server`). let is_special = is_special_url_scheme(&protocol); if is_special && !hostname.is_empty() { if let Some(canon) = super::whatwg_canonicalize_host(&hostname) { @@ -771,12 +712,6 @@ pub(crate) fn is_valid_absolute_url(s: &str) -> bool { if scheme_end >= s.len() || s.as_bytes()[scheme_end] != b':' { return false; } - // For hierarchical schemes the WHATWG parser requires a host for every - // special scheme except `file:`, and it accepts the `//` as optional - // (`https:x` and `https:/x` both host `x`). A `\` is a separator, never - // part of the authority, so validation reads the same folded text the - // parser does. For every other scheme Node accepts whatever follows `:`, - // empty included, and non-empty input is an opaque path. let after_scheme = &s[scheme_end + 1..]; let scheme = &s[..scheme_end]; if is_special_scheme_name(scheme) { @@ -787,9 +722,9 @@ pub(crate) fn is_valid_absolute_url(s: &str) -> bool { } else { after_scheme }; + // The `//` is optional for a special scheme, and `file:` also allows a + // hostless path. if scheme == "file" { - // The authority exists only in the `//` spelling; `file:`, `file:x` - // and `file:////x` are hostless paths with nothing to validate. if let Some(authority) = after_scheme.strip_prefix("//") { let authority = authority.split(['/', '?', '#']).next().unwrap_or_default(); if !is_valid_url_authority(scheme, authority) { @@ -804,9 +739,7 @@ pub(crate) fn is_valid_absolute_url(s: &str) -> bool { } } } else if let Some(authority) = after_scheme.strip_prefix("//") { - // A non-special `//` authority is a host too, and `\` is a forbidden - // host code point: `foo://a\b` is invalid in Node. Its path form - // (`foo://a/b\c`) stays valid — a path has no such restriction. + // `\` is a forbidden host code point, so `foo://a\b` is not a URL. let authority = authority.split(['/', '?', '#']).next().unwrap_or_default(); let host = authority .rsplit_once('@') @@ -840,9 +773,6 @@ pub(super) fn normalize_file_drive_authority(input: &str) -> String { .unwrap_or(authority_and_path.len()); let authority = &authority_and_path[..drive_end]; let drive_bytes = authority.as_bytes(); - // A `|` spelling is a Windows drive letter too (`file://C|/x`), so it takes - // the same drive-authority path and lets the path-level rule canonicalize - // the separators (`file:///C:/x`) (#12864). if drive_bytes.len() != 2 || !drive_bytes[0].is_ascii_alphabetic() || !matches!(drive_bytes[1], b':' | b'|') diff --git a/crates/perry-runtime/src/url/url_class.rs b/crates/perry-runtime/src/url/url_class.rs index 40d5d47f655..ddc67b89f90 100644 --- a/crates/perry-runtime/src/url/url_class.rs +++ b/crates/perry-runtime/src/url/url_class.rs @@ -3,7 +3,7 @@ use super::*; use super::parse::{ - create_url_object, is_special_url_scheme, is_valid_absolute_url, normalize_hierarchical_path, + create_url_object, is_special_url_scheme, is_valid_absolute_url, normalize_path, rebuild_url_host, rebuild_url_href, resolve_url, throw_invalid_url, URL_FIELD_COUNT, URL_HASH, URL_HOST, URL_HOSTNAME, URL_HREF, URL_ORIGIN, URL_PASSWORD, URL_PATHNAME, URL_PORT, URL_PROTOCOL, URL_SEARCH, URL_SEARCH_PARAMS, URL_USERNAME, @@ -251,14 +251,9 @@ pub extern "C" fn js_url_get_href(url: *mut ObjectHeader) -> f64 { crate::object::js_object_get_field_f64(url, URL_HREF) } -/// Issue #650: `url.pathname = value` setter. Per WHATWG the assignment clears -/// the URL's path and re-runs the parser's path states over the new value, so -/// everything construction does to a path applies here too: a `\` is a segment -/// separator for a special scheme, the value gains a leading separator when it -/// has none, and dot segments (and a `file:` Windows drive letter) are -/// canonicalized (#12864). Non-hierarchical URLs keep their opaque path, and a -/// non-special one keeps backslashes literally — `foo://x` + `/b\c` is -/// `/b\c`, matching Node. +/// Issue #650: `url.pathname = value` setter. Re-runs the parser's path states, +/// so the value is folded, rooted and normalized as in construction (#12864); +/// an opaque URL keeps whatever it is given. #[no_mangle] pub extern "C" fn js_url_set_pathname(url: *mut ObjectHeader, value: f64) { if url.is_null() { @@ -269,11 +264,12 @@ pub extern "C" fn js_url_set_pathname(url: *mut ObjectHeader, value: f64) { let host = get_string_content(crate::object::js_object_get_field_f64(url, URL_HOST)); let protocol = get_string_content(crate::object::js_object_get_field_f64(url, URL_PROTOCOL)); - let special = is_special_url_scheme(&protocol); let hierarchical = !host.is_empty() || protocol == "file:"; - // Fold before the leading-separator test: for a special URL `\a` is - // already a rooted path, so it must not gain a second separator. - let raw = if special { raw.replace('\\', "/") } else { raw }; + let raw = if is_special_url_scheme(&protocol) { + raw.replace('\\', "/") + } else { + raw + }; let normalized = if hierarchical && !raw.starts_with('/') { format!("/{}", raw) } else { @@ -281,7 +277,7 @@ pub extern "C" fn js_url_set_pathname(url: *mut ObjectHeader, value: f64) { }; let encoded = percent_encode_path(&normalized); let encoded = if hierarchical { - normalize_hierarchical_path(&encoded, protocol == "file:") + normalize_path(&encoded, protocol == "file:") } else { encoded };