Skip to content

fix(url): close the pathname and url-engine gaps found in #12864 - #13196

Merged
proggeramlug merged 3 commits into
mainfrom
fix/url-followups
Oct 24, 2026
Merged

proggeramlug merged 3 commits into
mainfrom
fix/url-followups

Conversation

@TheHypnoo

@TheHypnoo TheHypnoo commented Oct 24, 2026 •

Copy link
Copy Markdown
Member

Summary

Four URL defects found while normalizing paths in #13195, each independent and each confirmed against the pinned Node oracle. They are pathname/engine/resolver behavior, not parser shape, so they land separately from #13195.

#13195 has merged, so this is based on main and its diff contains only the follow-up work.

Changes

  • crates/perry-runtime/src/url/parse.rs — create_url_object encodes pathname with the shared path encoder instead of the non-ASCII-only one.
  • crates/perry-runtime/src/url/url_class.rs — the path encode set gains ^; percent_encode_path becomes the single encoder both paths use; js_url_set_pathname returns early for an opaque URL.
  • crates/perry/src/commands/compile/collect_modules/feature_detect.rs — the url-engine detector recognizes NativeModuleRef("url"), the marker a default/namespace node:url import leaves in the HIR.
  • crates/perry-runtime/src/url/node_compat.rs — url.resolve is a port of Node's resolveObject: it no longer delegates to the WHATWG resolver, and legacy_url_join plus its url-crate dependency are gone.
  • test-parity/node-suite/url/url/path-percent-encoding.ts, .../default-import-helpers.ts, .../legacy/resolve-relative-base.ts — new node-parity cases.
  • changelog.d/… — fragment.

The three defects

  1. Constructor path encoding. new URL("https://x/a b<c").pathname was /a b<c; Node reports /a%20b%3Cc. The setter had the right encoder, the constructor did not. ^ is now escaped too (/a^b → /a%5Eb), matching Node even though the standard's path set omits it. % is outside the set, so %20, %zz and %2G pass through untouched, and tab/LF/CR are stripped rather than encoded.

  2. Opaque pathname writes. new URL("mailto:a/../b") + url.pathname = "q/../r" rewrote the path to mailto:q/../r. Node ignores the assignment: a URL without a segment list has nothing to set. List paths (https:, file:, foo:/a) still take the write.

  3. url-engine not linked for default imports. import url from "node:url" + only helper calls produced NativeModuleRef("url") in the HIR, which the detector did not match, so the engine was never installed and domainToASCII/domainToUnicode fell back to plain IDNA: domainToASCII("a/b") returned "a/b" where Node returns "a". Adding that token fixes it — and the two remaining failing lines of test_gap_6375_legacy_url_parse_format.ts, which uses exactly that import shape.

  4. Legacy url.resolve with a non-absolute base. url.resolve("/a/b", "../c") returned ///c; Node returns /c. The helper delegated to the WHATWG resolve_url, which models absolute URLs, so a scheme-less base was parsed as host + path and re-emitted with a //host prefix it never had. The resolver now walks segments like Node's resolveObject: the base keeps its scheme and authority, the relative path replaces the base's last segment, and the up counter lets a relative base climb above its own root (("a/b", "../../c") is ../c) while an absolute one cannot. ?query keeps the base path verbatim and #hash keeps the base query.

Related issue

Refs #12864 (follow-up to #13195)

Test plan

  • cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static — clean, plus scripts/gc_call_effects/sidecars.sh target/release
  • PERRY_SKIP_BUILD=1 ./run_parity_tests.sh --suite node-suite --module url — 72/72 PASS (69 from fix(url): remove dot segments and fold special-scheme separators #13195 + the 3 new cases)
  • All three new cases byte-identical to node --experimental-strip-types (diff empty; 39 base/reference pairs in the resolver case)
  • The four reproductions now match Node exactly (/a%20b%3Cc, ignored opaque write, domainToASCII("a/b") === "a" without a URL constructor, url.resolve("/a/b", "../c") === "/c")
  • URL test-files corpus (29 cases): no new failure, and test_gap_6375_legacy_url_parse_format.ts — failing before this change — now passes
  • Full cargo test --workspace … — not run locally; CI's cargo-test job is the backstop
  • cargo fmt --check -p perry-runtime clean

Checklist

  • I have NOT bumped the workspace version or edited CLAUDE.md / CHANGELOG.md (maintainer handles these at merge)
  • My commits follow the loose feat: / fix: / docs: / chore: prefix convention used in the log
  • I've read CONTRIBUTING.md and agree to the Code of Conduct

Three separate defects surfaced while normalizing URL paths. Each is small and
independent; they share this PR because they are the same review surface.

1. The constructor only encoded non-ASCII in `pathname`, so `new URL()` kept
   raw spaces and delimiters that the WHATWG path percent-encode set escapes:
   `new URL("https://x/a b<c").pathname` was `/a b<c` where Node reports
   `/a%20b%3Cc`. `create_url_object` now uses the same path encoder as the
   `pathname` setter, and that encoder gains `^`, which Node escapes in paths
   (`/a^b` → `/a%5Eb`) although the standard's set omits it. `%` stays outside
   the set, so existing escapes and stray `%` pass through unchanged, and
   tab/LF/CR are stripped from the input instead of encoded.

2. Assigning `pathname` on an opaque URL wrote to it: `new URL("mailto:a/../b")`
   then `url.pathname = "q/../r"` rewrote the opaque path to `mailto:q/../r`.
   Node ignores the assignment, because a URL whose path is not a segment list
   has nothing to set.

3. A program importing `node:url` as a default or namespace object
   (`import url from "node:url"`) and calling only its helpers linked no
   `url-engine`: the HIR carries `NativeModuleRef("url")` rather than the
   `module: "url"` marker the feature scan looked for, so `domainToASCII` /
   `domainToUnicode` silently fell back to plain IDNA (`"a/b"` instead of
   Node's `"a"`). Detecting that token fixes it, and with it the last two
   failing lines of `test_gap_6375_legacy_url_parse_format.ts`, which uses
   exactly that import shape.

New node-parity cases: `path-percent-encoding.ts` (constructor escapes, `%`
passthrough, whitespace stripping, opaque-vs-list `pathname` writes) and
`default-import-helpers.ts` (deliberately constructs no URL, which is the
trigger for defect 3). `--suite node-suite --module url` is 71/71, and the
URL `test-files` corpus shows no new failure: `test_gap_6375` now passes.
@coderabbitai

coderabbitai Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Caution

Review failed

The pull request is closed.

Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

`url.resolve("/a/b", "../c")` returned `///c` where Node returns `/c`: the
helper delegated to the WHATWG `resolve_url`, which models absolute URLs, so a
base without a scheme was parsed as host + path and then re-emitted with a
`//host` prefix it never had.

The resolver is now a port of Node's `Url.prototype.resolveObject` segment walk.
The base keeps its scheme and authority, the relative path replaces the base's
last segment, and dot segments are resolved with the `up` counter that lets a
*relative* base climb above its own root (`("a/b", "../../c")` is `../c`) while
an absolute one cannot. `?query` keeps the base path verbatim, a `#hash` keeps
the base query, and an absolute or protocol-relative target still replaces
everything. `legacy_url_join` and its `url`-crate dependency are gone.

New case `test-parity/node-suite/url/legacy/resolve-relative-base.ts` pins 39
base/reference pairs; `--suite node-suite --module url` is 72/72 and the URL
`test-files` corpus shows no new failure.
@TheHypnoo
TheHypnoo marked this pull request as ready for review October 24, 2026 18:09
@proggeramlug
proggeramlug merged commit 7008e3b into main Oct 24, 2026
71 of 72 checks passed
@proggeramlug
proggeramlug deleted the fix/url-followups branch October 24, 2026 18:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants