Skip to main content

nodejs/stdlib/
url.rs

1//! Node `url` module: the WHATWG `URL` class (global + `require('url').URL`) and
2//! the legacy `url.parse`. A `URL` instance stores its components as data
3//! properties (so `u.hostname` reads directly) plus a `@@native = "URL"` tag for
4//! `toString`. Assigning one of those components goes through [`refresh`], which
5//! rewrites the DERIVED fields (`href`, `host`, `origin`) so the object cannot
6//! disagree with itself; the `searchParams` it carries holds an `@@ownerUrl`
7//! back-reference so its own mutations rewrite the query in the other direction.
8//!
9//! They remain OWN properties of the instance, where node has them as accessors
10//! on `URL.prototype` — so `Object.keys(url)` lists twelve names here and none
11//! in node.
12
13use super::arg_str;
14use crate::host::{with_host, JsObj};
15use fusevm::Value;
16use indexmap::IndexMap;
17
18pub const MODULE_METHODS: &[&str] = &[
19    "parse",
20    "format",
21    "fileURLToPath",
22    "fileURLToPathBuffer",
23    "pathToFileURL",
24    "domainToASCII",
25    "domainToUnicode",
26    "urlToHttpOptions",
27    "resolve",
28    "resolveObject",
29];
30
31/// Parsed URL components.
32/// The component names a `URL` exposes as writable ACCESSORS on its prototype.
33///
34/// Assigning one has to rewrite the DERIVED fields — `href`, `host` and
35/// `origin` — which are stored alongside rather than computed on read. Without
36/// that, `u.pathname = '/p'` read back as `/p` while `u.href` still showed the
37/// old path, so the object disagreed with itself.
38///
39/// `host` and `href` are here too, and both need more than a write: `host`
40/// carries the port, and assigning `href` REPLACES the whole URL. Neither was
41/// settable, so `u.href = 'http://x/y'` stored a string that every other
42/// property then contradicted.
43pub const COMPONENTS: &[&str] = &[
44    "protocol", "username", "password", "host", "hostname", "port", "pathname", "search", "hash",
45    "href",
46];
47
48/// Whether `name` is a `URL` component whose assignment must refresh the
49/// derived fields.
50pub fn is_component(name: &str) -> bool {
51    COMPONENTS.contains(&name)
52}
53
54/// Recompute `href`, `host` and `origin` from the component properties now on
55/// `url`, and normalise the two components that carry a leading delimiter.
56///
57/// `sync_params` rewrites the attached `searchParams` from the new query. It is
58/// false when the caller IS that `searchParams` object pushing its own edit
59/// back, which would otherwise recurse.
60fn recompute(url: &Value, sync_params: bool) {
61    let read = |k: &str| {
62        with_host(|h| match h.get(url) {
63            Some(JsObj::Object(p)) => p.get(k).map(|v| h.str_of(v)).unwrap_or_default(),
64            _ => String::new(),
65        })
66    };
67    let mut protocol = read("@@protocol");
68    if !protocol.is_empty() && !protocol.ends_with(':') {
69        protocol.push(':');
70    }
71    // A search or hash assigned without its delimiter gains one; assigning the
72    // empty string clears it, as the WHATWG setters do.
73    let delimited = |s: String, lead: char| {
74        if s.is_empty() || s.starts_with(lead) {
75            s
76        } else {
77            format!("{lead}{s}")
78        }
79    };
80    let parts = Parts {
81        protocol,
82        username: read("@@username"),
83        password: read("@@password"),
84        hostname: read("@@hostname"),
85        port: read("@@port"),
86        pathname: read("@@pathname"),
87        search: delimited(read("@@search"), '?'),
88        hash: delimited(read("@@hash"), '#'),
89    };
90    let (href, host, origin) = (parts.href(), parts.host(), parts.origin());
91    let search = parts.search.clone();
92    if sync_params {
93        // The attached `searchParams` is updated IN PLACE: node hands out one
94        // object per URL for the life of the URL, so `u.searchParams` before and
95        // after `u.search = …` is the same object.
96        let query = search.strip_prefix('?').unwrap_or(&search).to_string();
97        let params = with_host(|h| match h.get(url) {
98            Some(JsObj::Object(p)) => p.get("@@searchParams").cloned(),
99            _ => None,
100        });
101        if let Some(params) = params {
102            write_pairs(&params, &parse_query(&query));
103        }
104    }
105    with_host(|h| {
106        let vals = [
107            ("@@href", h.new_str(href)),
108            ("@@host", h.new_str(host)),
109            ("@@origin", h.new_str(origin)),
110            ("@@protocol", h.new_str(parts.protocol.clone())),
111            ("@@search", h.new_str(search)),
112            ("@@hash", h.new_str(parts.hash.clone())),
113        ];
114        if let Some(JsObj::Object(p)) = h.get_mut(url) {
115            for (k, v) in vals {
116                p.insert(k.to_string(), v);
117            }
118        }
119    });
120}
121
122/// Refresh a `URL` after one of its components was assigned.
123pub fn refresh(url: &Value) {
124    recompute(url, true);
125}
126
127/// Split the `host` just assigned to `url` into the `hostname` and `port` it
128/// actually carries.
129///
130/// `host` is DERIVED from those two on every refresh, so writing it as one
131/// string was undone immediately: `u.host = 'b:99'` left the URL pointing at
132/// the old host entirely.
133pub fn split_host(url: &Value) {
134    let host = with_host(|h| match h.get(url) {
135        Some(JsObj::Object(p)) => p.get("@@host").map(|v| h.str_of(v)).unwrap_or_default(),
136        _ => String::new(),
137    });
138    // An IPv6 literal keeps its brackets; the port is whatever follows the LAST
139    // colon outside them.
140    let split = match host.rfind(']') {
141        Some(i) => host[i..].find(':').map(|j| i + j),
142        None => host.rfind(':'),
143    };
144    let (hostname, port) = match split {
145        Some(i) => (host[..i].to_string(), host[i + 1..].to_string()),
146        None => (host.clone(), String::new()),
147    };
148    with_host(|h| {
149        let (hn, pt) = (h.new_str(hostname), h.new_str(port));
150        if let Some(JsObj::Object(p)) = h.get_mut(url) {
151            p.insert("@@hostname".into(), hn);
152            p.insert("@@port".into(), pt);
153        }
154    });
155    refresh(url);
156}
157
158/// Re-parse `url` from the `href` just assigned to it.
159///
160/// `href` is not a component: it is the WHOLE URL, so setting it replaces every
161/// other field. Treating it as one more stored string left `u.host` and
162/// `u.pathname` reporting the old URL's values while `u.href` showed the new
163/// one. An unparseable value is ignored, which is what node does — its `href`
164/// setter throws only for a value no parser can accept, and this parser is the
165/// one deciding that.
166pub fn reparse(url: &Value) {
167    let href = with_host(|h| match h.get(url) {
168        Some(JsObj::Object(p)) => p.get("@@href").map(|v| h.str_of(v)).unwrap_or_default(),
169        _ => String::new(),
170    });
171    let Some(parts) = parse_absolute(&href) else {
172        return;
173    };
174    let fresh = build(&parts);
175    let props = with_host(|h| match h.get(&fresh) {
176        Some(JsObj::Object(p)) => p.clone(),
177        _ => IndexMap::new(),
178    });
179    with_host(|h| {
180        if let Some(JsObj::Object(p)) = h.get_mut(url) {
181            for (k, v) in props {
182                p.insert(k, v);
183            }
184        }
185    });
186}
187
188struct Parts {
189    protocol: String,
190    username: String,
191    password: String,
192    hostname: String,
193    port: String,
194    pathname: String,
195    search: String,
196    hash: String,
197}
198
199impl Parts {
200    fn host(&self) -> String {
201        if self.port.is_empty() {
202            self.hostname.clone()
203        } else {
204            format!("{}:{}", self.hostname, self.port)
205        }
206    }
207    fn origin(&self) -> String {
208        // Only a special scheme with a network host has a tuple origin; every
209        // other URL (`foo://h/`, `redis://h:1/`, `file:///x`) is opaque: `null`.
210        let scheme = self.protocol.strip_suffix(':').unwrap_or(&self.protocol);
211        if self.hostname.is_empty() || special_port(scheme).is_none() {
212            "null".into()
213        } else {
214            format!("{}//{}", self.protocol, self.host())
215        }
216    }
217    fn href(&self) -> String {
218        let auth = if self.username.is_empty() {
219            String::new()
220        } else if self.password.is_empty() {
221            format!("{}@", self.username)
222        } else {
223            format!("{}:{}@", self.username, self.password)
224        };
225        format!(
226            "{}//{auth}{}{}{}{}",
227            self.protocol,
228            self.host(),
229            self.pathname,
230            self.search,
231            self.hash
232        )
233    }
234}
235
236/// Whether `scheme` is one of the WHATWG "special" schemes, whose parsing
237/// normalizes backslashes and drops a default port.
238fn special_port(scheme: &str) -> Option<&'static str> {
239    match scheme {
240        "http" | "ws" => Some("80"),
241        "https" | "wss" => Some("443"),
242        "ftp" => Some("21"),
243        _ => None,
244    }
245}
246
247/// Parse an absolute URL. Returns `None` if there is no `scheme://`.
248fn parse_absolute(input: &str) -> Option<Parts> {
249    // The URL parser REMOVES every tab and newline from the input before doing
250    // anything else, rather than treating them as content. They were surviving
251    // into the components and then being percent-encoded.
252    let stripped: String;
253    let input = if input.contains(['\t', '\n', '\r']) {
254        stripped = input.replace(['\t', '\n', '\r'], "");
255        stripped.as_str()
256    } else {
257        input
258    };
259    let (scheme, rest) = input.split_once("://")?;
260    // For a special scheme a backslash is a path separator, not a character —
261    // in the AUTHORITY too, where it terminates the userinfo. It is NOT one in
262    // the query or fragment, where node keeps it literal, so the rewrite stops
263    // at whichever of `?`/`#` comes first.
264    let backslashed: String;
265    let rest = if special_port(&scheme.to_ascii_lowercase()).is_some() && rest.contains('\\') {
266        let cut = rest.find(['?', '#']).unwrap_or(rest.len());
267        backslashed = format!("{}{}", rest[..cut].replace('\\', "/"), &rest[cut..]);
268        backslashed.as_str()
269    } else {
270        rest
271    };
272    if scheme.is_empty()
273        || !scheme
274            .chars()
275            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
276    {
277        return None;
278    }
279    // A special scheme ignores any further slashes before the authority
280    // ("special authority ignore slashes state"): `http:///a` is `http://a/`.
281    let rest = if special_port(&scheme.to_ascii_lowercase()).is_some() {
282        rest.trim_start_matches('/')
283    } else {
284        rest
285    };
286    // authority is up to the first '/', '?' or '#'.
287    let auth_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
288    let authority = &rest[..auth_end];
289    let mut tail = &rest[auth_end..];
290
291    let (userinfo, hostport) = match authority.rsplit_once('@') {
292        Some((u, h)) => (u, h),
293        None => ("", authority),
294    };
295    let (username, password) = match userinfo.split_once(':') {
296        Some((u, p)) => (u.to_string(), p.to_string()),
297        None => (userinfo.to_string(), String::new()),
298    };
299    // An IPv6 literal carries colons of its own: the port separator is the
300    // first colon AFTER its closing bracket, and nothing else may sit there.
301    let (hostname, port) = if hostport.starts_with('[') {
302        let close = hostport.find(']')?;
303        match &hostport[close + 1..] {
304            "" => (&hostport[..=close], ""),
305            p => (&hostport[..=close], p.strip_prefix(':')?),
306        }
307    } else {
308        hostport.split_once(':').unwrap_or((hostport, ""))
309    };
310    let lower_scheme = scheme.to_ascii_lowercase();
311    let special = special_port(&lower_scheme).is_some();
312    // The host parser: a special scheme's host is a domain (percent-decoded,
313    // mapped to ASCII, and checked for forbidden code points), an IPv4 address
314    // in any of its number forms, or a bracketed IPv6 address, each serialized
315    // canonically — `http://0x7f.1/` is `http://127.0.0.1/`, and `http://a b/`
316    // is no URL at all. Any other scheme's host is opaque and only checked.
317    let hostname = if special {
318        if hostname.is_empty() {
319            return None;
320        }
321        url::Host::parse(hostname).ok()?.to_string()
322    } else if hostname.is_empty() {
323        String::new()
324    } else {
325        url::Host::parse_opaque(hostname).ok()?.to_string()
326    };
327    // A port is digits only and at most 65535, serialized without leading
328    // zeros; an empty port after the colon is the same as none.
329    let port = if port.is_empty() {
330        String::new()
331    } else if port.bytes().all(|b| b.is_ascii_digit()) {
332        port.trim_start_matches('0').parse::<u16>().map_or_else(
333            |_| {
334                if port.bytes().all(|b| b == b'0') {
335                    Some("0".to_string())
336                } else {
337                    None
338                }
339            },
340            |n| Some(n.to_string()),
341        )?
342    } else {
343        return None;
344    };
345
346    let hash = match tail.find('#') {
347        Some(i) => {
348            let h = tail[i..].to_string();
349            tail = &tail[..i];
350            h
351        }
352        None => String::new(),
353    };
354    let search = match tail.find('?') {
355        Some(i) => {
356            let s = tail[i..].to_string();
357            tail = &tail[..i];
358            s
359        }
360        None => String::new(),
361    };
362    // A scheme is case-insensitive and reported lower-case.
363    let scheme = scheme.to_ascii_lowercase();
364    let default_port = special_port(&scheme);
365    let pathname = if tail.is_empty() {
366        "/".to_string()
367    } else {
368        normalize_path(tail)
369    };
370    // The scheme's default port is not part of the serialization.
371    let port = if default_port == Some(port.as_str()) {
372        String::new()
373    } else {
374        port
375    };
376
377    Some(Parts {
378        protocol: format!("{scheme}:"),
379        username,
380        password,
381        hostname,
382        port,
383        pathname,
384        search,
385        hash,
386    })
387}
388
389/// Collapse `.` and `..` segments in an absolute-ish URL path, per the WHATWG
390/// URL path-state machine: `.` drops, `..` pops the previous segment (never past
391/// the root), and a trailing `.`/`..` leaves a trailing slash
392/// (`/a/b/../../../c` → `/c`, `/a/b/..` → `/a/`).
393fn normalize_path(path: &str) -> String {
394    if !path.contains('.') {
395        return path.to_string();
396    }
397    let rooted = path.starts_with('/');
398    let mut out: Vec<&str> = Vec::new();
399    let mut trailing_slash = false;
400    for seg in path.split('/') {
401        match seg {
402            "." => trailing_slash = true,
403            ".." => {
404                out.pop();
405                trailing_slash = true;
406            }
407            _ => {
408                out.push(seg);
409                trailing_slash = false;
410            }
411        }
412    }
413    // `split` on a rooted path yields a leading "" that rebuilds the root slash;
414    // a `..` may have popped it, so restore it.
415    if rooted && out.first() != Some(&"") {
416        out.insert(0, "");
417    }
418    let mut joined = out.join("/");
419    if trailing_slash && !joined.ends_with('/') {
420        joined.push('/');
421    }
422    if joined.is_empty() {
423        joined.push('/');
424    }
425    joined
426}
427
428/// `new URL(input[, base])`.
429pub fn construct(args: &[Value]) -> Result<Value, String> {
430    // Both arguments go through ToString, so an object's own `toString` is
431    // what gets parsed (`new URL('x', { toString() { return 'http://a/' } })`).
432    let to_str = |v: &Value| {
433        crate::host::to_string_value(v).map(|s| crate::host::with_host(|h| h.str_of(&s)))
434    };
435    let input = match args.first() {
436        Some(v) => to_str(v)?,
437        None => "undefined".to_string(),
438    };
439    // An explicit `undefined` base is no base at all.
440    let base = match args.get(1) {
441        Some(Value::Undef) | None => None,
442        Some(v) => Some(to_str(v)?),
443    };
444    let parts = parse_absolute(&input)
445        .or_else(|| {
446            // A base makes a relative input absolute (path replacement only).
447            if let Some(base) = &base {
448                parse_absolute(base).map(|mut b| {
449                    // Split the RELATIVE reference's own query/fragment off first;
450                    // they replace the base's, they do not append to its path.
451                    let mut rest = input.as_str();
452                    let hash = match rest.find('#') {
453                        Some(i) => {
454                            let h = rest[i..].to_string();
455                            rest = &rest[..i];
456                            h
457                        }
458                        None => String::new(),
459                    };
460                    let search = match rest.find('?') {
461                        Some(i) => {
462                            let q = rest[i..].to_string();
463                            rest = &rest[..i];
464                            q
465                        }
466                        None => String::new(),
467                    };
468                    // A rooted reference replaces the path; anything else resolves
469                    // against the base's DIRECTORY (everything up to its last `/`).
470                    let merged = if rest.starts_with('/') {
471                        rest.to_string()
472                    } else if rest.is_empty() {
473                        b.pathname.clone()
474                    } else {
475                        let dir = match b.pathname.rfind('/') {
476                            Some(i) => &b.pathname[..=i],
477                            None => "/",
478                        };
479                        format!("{dir}{rest}")
480                    };
481                    b.pathname = normalize_path(&merged);
482                    b.search = search;
483                    b.hash = hash;
484                    b
485                })
486            } else {
487                None
488            }
489        })
490        // Node's message is the bare `Invalid URL` and it carries
491        // `code === 'ERR_INVALID_URL'`; the input is exposed as `err.input`, not
492        // appended to the text. `url_legacy::invalid_url` was already emitting
493        // the current form — this site was the one still hardcoding an older one.
494        // node also hangs the input (and the base, when one was passed) off the
495        // error as `err.input` / `err.base`.
496        .ok_or_else(|| {
497            let mut fields = vec![("input", input.as_str())];
498            if let Some(b) = &base {
499                fields.push(("base", b.as_str()));
500            }
501            crate::host::plain_coded_error_with(
502                "TypeError",
503                "ERR_INVALID_URL",
504                "Invalid URL",
505                &fields,
506            )
507        })?;
508    Ok(build(&parts))
509}
510
511/// Percent-encode `s` for one URL component, per the WHATWG percent-encode sets.
512///
513/// None of this was happening: `new URL('https://a.b/a b?c=d e').href` came back
514/// with the spaces intact, which is not a valid URL and does not round-trip.
515///
516/// The sets below were derived by feeding every ASCII character through node
517/// v26.8.1 in each position rather than transcribed, since the spec's sets and
518/// what a parser actually emits differ around the component delimiters. Every
519/// C0 control, `%7F`, and every non-ASCII byte is encoded in all four; a byte
520/// already part of a valid `%XX` escape is left alone so re-parsing a URL does
521/// not double-encode it.
522fn percent_encode(s: &str, extra: &str) -> String {
523    let bytes = s.as_bytes();
524    let mut out = String::with_capacity(s.len());
525    let mut i = 0;
526    while i < bytes.len() {
527        let b = bytes[i];
528        // An existing escape passes through untouched.
529        if b == b'%' && i + 2 < bytes.len() + 1 {
530            let hex = bytes.get(i + 1..i + 3);
531            if hex.is_some_and(|h| h.iter().all(|c| c.is_ascii_hexdigit())) {
532                out.push('%');
533                out.push(bytes[i + 1] as char);
534                out.push(bytes[i + 2] as char);
535                i += 3;
536                continue;
537            }
538        }
539        if b < 0x20 || b == 0x7f || b >= 0x80 || extra.as_bytes().contains(&b) {
540            out.push_str(&format!("%{b:02X}"));
541        } else {
542            out.push(b as char);
543        }
544        i += 1;
545    }
546    out
547}
548
549/// The four component encode sets, as measured against node.
550const PATH_SET: &str = " \"<>^`{}";
551const QUERY_SET: &str = " \"'<>";
552const FRAGMENT_SET: &str = " \"<>`";
553const USERINFO_SET: &str = " \";<=>@[]^`{|}";
554
555fn build(p: &Parts) -> Value {
556    // Percent-encode each component once, here, so `href()` and every
557    // individual property report the same normalized text. The host arrives
558    // already canonical from the host parser in `parse_absolute`; lower-casing
559    // it again here also folded a non-special scheme's opaque host, which
560    // node keeps as written (`foo://Host/`).
561    let p = &Parts {
562        protocol: p.protocol.clone(),
563        username: percent_encode(&p.username, USERINFO_SET),
564        password: percent_encode(&p.password, USERINFO_SET),
565        hostname: p.hostname.clone(),
566        port: p.port.clone(),
567        pathname: percent_encode(&p.pathname, PATH_SET),
568        search: percent_encode(&p.search, QUERY_SET),
569        hash: percent_encode(&p.hash, FRAGMENT_SET),
570    };
571    // Build the `URLSearchParams` BEFORE the allocating `with_host` below (never
572    // nest `with_host`); it is stored as the `searchParams` data property so
573    // `url.searchParams.get(...)` reads it directly. It is LIVE, not a snapshot:
574    // it gets an `@@ownerUrl` back-reference below so that mutating it rewrites
575    // this URL's `search` and `href`.
576    let query = p.search.strip_prefix('?').unwrap_or(&p.search);
577    let search_params = make_search_params(&parse_query(query));
578    with_host(|h| {
579        let mut m = IndexMap::new();
580        m.insert("@@native".into(), h.new_str("URL"));
581        m.insert("@@href".into(), h.new_str(p.href()));
582        m.insert("@@origin".into(), h.new_str(p.origin()));
583        m.insert("@@protocol".into(), h.new_str(p.protocol.clone()));
584        m.insert("@@username".into(), h.new_str(p.username.clone()));
585        m.insert("@@password".into(), h.new_str(p.password.clone()));
586        m.insert("@@host".into(), h.new_str(p.host()));
587        m.insert("@@hostname".into(), h.new_str(p.hostname.clone()));
588        m.insert("@@port".into(), h.new_str(p.port.clone()));
589        m.insert("@@pathname".into(), h.new_str(p.pathname.clone()));
590        m.insert("@@search".into(), h.new_str(p.search.clone()));
591        m.insert("@@searchParams".into(), search_params.clone());
592        m.insert("@@hash".into(), h.new_str(p.hash.clone()));
593        let obj = h.new_object(m);
594        // Hidden, and set after the URL exists so the two can point at each other.
595        if let Some(JsObj::Object(sp)) = h.get_mut(&search_params) {
596            sp.insert("@@ownerUrl".into(), obj.clone());
597        }
598        obj
599    })
600}
601
602/// Statics on the `URL` CLASS — distinct from [`MODULE_METHODS`], which are the
603/// legacy `require('url')` functions.
604///
605/// `createObjectURL`/`revokeObjectURL` are absent because `Blob` is not
606/// implemented; they would have nothing to register.
607pub const STATIC_METHODS: &[&str] = &["canParse", "parse"];
608
609/// `URL.canParse(input[, base])` / `URL.parse(input[, base])`.
610///
611/// Both are the non-throwing form of the constructor: `canParse` reports
612/// whether parsing succeeds, `parse` returns the `URL` or `null`. Neither
613/// existed, so `URL.canParse` was a TypeError rather than a boolean.
614pub fn static_call(method: &str, args: &[Value]) -> Option<Result<Value, String>> {
615    let parsed = construct(args);
616    Some(match method {
617        "canParse" => Ok(Value::Bool(parsed.is_ok())),
618        "parse" => Ok(parsed.unwrap_or_else(|_| with_host(|h| h.null()))),
619        _ => return None,
620    })
621}
622
623pub fn call(method: &str, args: &[Value]) -> Option<Result<Value, String>> {
624    Some(match method {
625        "parse" => legacy_parse(args).map(|u| super::url_legacy::to_js(&u)),
626        "format" => super::url_legacy::format_value(&args.first().cloned().unwrap_or(Value::Undef)),
627        // `url.fileURLToPath(url)` — a `file:` URL/string → a filesystem path
628        // (percent-decoded). POSIX best-effort: any authority (host) is accepted
629        // but not re-prefixed; Windows drive/UNC rewriting is not modeled.
630        "fileURLToPath" => file_url_to_path(args).map(|s| with_host(|h| h.new_str(s))),
631        // Same, but returns the path as a `Buffer`.
632        "fileURLToPathBuffer" => {
633            file_url_to_path(args).map(|s| super::buffer::from_bytes(s.as_bytes()))
634        }
635        // `url.pathToFileURL(path)` → a `URL` instance with a `file:` href.
636        "pathToFileURL" => Ok(path_to_file_url(&arg_str(args, 0))),
637        // `url.domainToASCII` / `url.domainToUnicode` — delegate to the punycode
638        // codec; an ASCII-only domain passes through unchanged, an invalid domain
639        // yields "" (matching Node, which never throws here).
640        "domainToASCII" => Ok(punycode_domain(args, true)),
641        "domainToUnicode" => Ok(punycode_domain(args, false)),
642        // `url.urlToHttpOptions(URL)` → an options object for http/https.request.
643        "urlToHttpOptions" => Ok(url_to_http_options(
644            &args.first().cloned().unwrap_or(Value::Undef),
645        )),
646        // Legacy `url.resolve(from, to)` — `urlParse(from, false, true)
647        // .resolve(to)`: both sides parsed with `slashesDenoteHost`, resolved by
648        // the `Url.prototype.resolveObject` port, then formatted.
649        "resolve" => legacy_resolve_object(args)
650            .map(|u| with_host(|h| h.new_str(u.href.unwrap_or_default()))),
651        // Legacy `url.resolveObject(from, to)` — the same resolution, returned
652        // as the parsed object. An empty `from` hands `to` back untouched.
653        "resolveObject" => {
654            if !args.first().is_some_and(|v| with_host(|h| h.truthy(v))) {
655                return Some(Ok(args.get(1).cloned().unwrap_or(Value::Undef)));
656            }
657            legacy_resolve_object(args).map(|u| super::url_legacy::to_js(&u))
658        }
659        _ => return None,
660    })
661}
662
663/// Legacy `url.parse(urlString[, parseQueryString[, slashesDenoteHost]])`.
664/// Emits the one-shot `DEP0169` deprecation warning, exactly as Node's
665/// `urlParse` does, then delegates to the `Url.prototype.parse` port.
666fn legacy_parse(args: &[Value]) -> Result<super::url_legacy::Url, String> {
667    emit_url_parse_deprecation();
668    let input = arg_str(args, 0);
669    let truthy = |i: usize| {
670        args.get(i)
671            .map(|v| with_host(|h| h.truthy(v)))
672            .unwrap_or(false)
673    };
674    super::url_legacy::parse(&input, truthy(1), truthy(2))
675}
676
677/// `urlParse`'s one-time `DEP0169`, shared by `parse`, `resolve` and
678/// `resolveObject` — all three go through `urlParse` in node.
679fn emit_url_parse_deprecation() {
680    super::process::emit_deprecation_warning(
681        "DEP0169",
682        "`url.parse()` behavior is not standardized and prone to errors that \
683         have security implications. Use the WHATWG URL API instead. CVEs are \
684         not issued for `url.parse()` vulnerabilities.",
685    );
686}
687
688/// `urlParse(args[0], false, true).resolveObject(args[1])`, emitting the
689/// one-shot `DEP0169` that `urlParse` raises.
690fn legacy_resolve_object(args: &[Value]) -> Result<super::url_legacy::Url, String> {
691    emit_url_parse_deprecation();
692    let source = super::url_legacy::parse(&arg_str(args, 0), false, true)?;
693    let relative = super::url_legacy::parse(&arg_str(args, 1), false, true)?;
694    Ok(super::url_legacy::resolve_object(&source, relative))
695}
696
697/// `URL` instance methods (component reads are plain data properties).
698pub fn instance_call(recv: &Value, method: &str, _args: &[Value]) -> Result<Value, String> {
699    match method {
700        "toString" | "toJSON" => Ok(with_host(|h| match h.get(recv) {
701            Some(JsObj::Object(p)) => p.get("@@href").cloned().unwrap_or(Value::Undef),
702            _ => Value::Undef,
703        })),
704        _ => Err(crate::host::type_error(&format!(
705            "url.{method} is not a function"
706        ))),
707    }
708}
709
710// ── file:/legacy URL helpers ─────────────────────────────────────────────────
711
712/// The `href` string of a value: for a native `URL` its stored `href`, else the
713/// value coerced to a string (so both `URL` objects and strings are accepted).
714fn url_href(v: &Value) -> String {
715    with_host(|h| match h.get(v) {
716        Some(JsObj::Object(p)) => match p.get("@@native").map(|x| h.str_of(x)).as_deref() {
717            Some("URL") => p.get("@@href").map(|x| h.str_of(x)).unwrap_or_default(),
718            _ => h.str_of(v),
719        },
720        _ => h.str_of(v),
721    })
722}
723
724/// `fileURLToPath` core: `file://[host]/path` → decoded `/path`.
725fn file_url_to_path(args: &[Value]) -> Result<String, String> {
726    let v = args.first().cloned().unwrap_or(Value::Undef);
727    let href = url_href(&v);
728    let rest = href.strip_prefix("file://").ok_or_else(|| {
729        crate::host::plain_coded_error(
730            "TypeError",
731            "ERR_INVALID_URL_SCHEME",
732            "The URL must be of scheme file",
733        )
734    })?;
735    // The authority runs up to the first '/'; the remainder is the path.
736    let path = match rest.find('/') {
737        Some(0) => rest,
738        Some(i) => &rest[i..],
739        None => "/",
740    };
741    Ok(percent_decode(path))
742}
743
744/// `pathToFileURL(path)` → a `URL` instance whose href is `file://` + the
745/// percent-encoded (path-set) path.
746fn path_to_file_url(path: &str) -> Value {
747    let enc = encode_path_component(path);
748    let pathname = if enc.starts_with('/') {
749        enc
750    } else {
751        format!("/{enc}")
752    };
753    let parts = Parts {
754        protocol: "file:".into(),
755        username: String::new(),
756        password: String::new(),
757        hostname: String::new(),
758        port: String::new(),
759        pathname,
760        search: String::new(),
761        hash: String::new(),
762    };
763    build(&parts)
764}
765
766/// `domainToASCII` (`ascii = true`) / `domainToUnicode` — via the punycode codec.
767fn punycode_domain(args: &[Value], ascii: bool) -> Value {
768    let method = if ascii { "toASCII" } else { "toUnicode" };
769    match super::punycode::call(method, args) {
770        Some(Ok(v)) => v,
771        _ => with_host(|h| h.new_str("")),
772    }
773}
774
775/// `urlToHttpOptions(URL)` → `{ protocol, hostname, hash, search, pathname, path,
776/// href[, port][, auth] }`, mirroring Node's field set and IPv6 bracket-stripping.
777fn url_to_http_options(v: &Value) -> Value {
778    let get = |key: &str| -> String {
779        with_host(|h| match h.get(v) {
780            Some(JsObj::Object(p)) => p.get(key).map(|x| h.str_of(x)).unwrap_or_default(),
781            _ => String::new(),
782        })
783    };
784    let protocol = get("@@protocol");
785    let mut hostname = get("@@hostname");
786    if hostname.starts_with('[') && hostname.ends_with(']') && hostname.len() >= 2 {
787        hostname = hostname[1..hostname.len() - 1].to_string();
788    }
789    let hash = get("@@hash");
790    let search = get("@@search");
791    let pathname = get("@@pathname");
792    let href = get("@@href");
793    let port = get("@@port");
794    let username = get("@@username");
795    let password = get("@@password");
796    let path = format!("{pathname}{search}");
797    let auth = if username.is_empty() && password.is_empty() {
798        None
799    } else {
800        Some(format!(
801            "{}:{}",
802            percent_decode(&username),
803            percent_decode(&password)
804        ))
805    };
806    let port_num = if port.is_empty() {
807        None
808    } else {
809        port.parse::<f64>().ok()
810    };
811    with_host(|h| {
812        let mut m = IndexMap::new();
813        m.insert("protocol".into(), h.new_str(protocol));
814        m.insert("hostname".into(), h.new_str(hostname));
815        m.insert("hash".into(), h.new_str(hash));
816        m.insert("search".into(), h.new_str(search));
817        m.insert("pathname".into(), h.new_str(pathname));
818        m.insert("path".into(), h.new_str(path));
819        m.insert("href".into(), h.new_str(href));
820        if let Some(n) = port_num {
821            m.insert("port".into(), Value::Float(n));
822        }
823        if let Some(a) = auth {
824            m.insert("auth".into(), h.new_str(a));
825        }
826        h.new_object(m)
827    })
828}
829
830/// Percent-decode a URL component (`%XX` → byte, then UTF-8 lossy). Unlike the
831/// form decoder this leaves `+` literal (a file path may legitimately contain it).
832pub(crate) fn percent_decode(s: &str) -> String {
833    let b = s.as_bytes();
834    let mut out: Vec<u8> = Vec::with_capacity(b.len());
835    let mut i = 0;
836    while i < b.len() {
837        if b[i] == b'%' && i + 2 < b.len() {
838            if let (Some(hi), Some(lo)) = (hex_val(b[i + 1]), hex_val(b[i + 2])) {
839                out.push((hi << 4) | lo);
840                i += 3;
841                continue;
842            }
843        }
844        out.push(b[i]);
845        i += 1;
846    }
847    String::from_utf8_lossy(&out).into_owned()
848}
849
850/// Percent-encode a path for a `file:` URL: keep the unreserved + sub-delim set
851/// and `/ : @`, encode everything else (space, `# ? %` `< > "` etc.).
852fn encode_path_component(s: &str) -> String {
853    let mut out = String::with_capacity(s.len());
854    for &b in s.as_bytes() {
855        let keep = b.is_ascii_alphanumeric()
856            || matches!(
857                b,
858                b'/' | b'-'
859                    | b'.'
860                    | b'_'
861                    | b'~'
862                    | b'!'
863                    | b'$'
864                    | b'&'
865                    | b'\''
866                    | b'('
867                    | b')'
868                    | b'*'
869                    | b'+'
870                    | b','
871                    | b';'
872                    | b'='
873                    | b':'
874                    | b'@'
875            );
876        if keep {
877            out.push(b as char);
878        } else {
879            out.push('%');
880            out.push(hex_upper(b >> 4));
881            out.push(hex_upper(b & 0x0f));
882        }
883    }
884    out
885}
886
887// ── URLSearchParams ──────────────────────────────────────────────────────────
888//
889// A `URLSearchParams` is a plain object tagged `@@native = "URLSearchParams"`
890// whose ordered `[key, value]` pairs live in a hidden `@@pairs` array (each entry
891// a 2-element `[key, value]` array of strings). All string coercion happens up
892// front; methods mutate a plain `Vec<(String, String)>` and write it back.
893
894/// Method names dispatched through `search_params_call` (for `instance_has_method`
895/// wiring in `stdlib::mod`; `@@iterator` makes `[...params]` / `for..of` work).
896pub const SEARCH_PARAMS_METHODS: &[&str] = &[
897    "get",
898    "getAll",
899    "has",
900    "set",
901    "append",
902    "delete",
903    "keys",
904    "values",
905    "entries",
906    "forEach",
907    "toString",
908    "sort",
909    "@@iterator",
910];
911
912/// Build a `URLSearchParams` native object from ordered key/value pairs.
913fn make_search_params(pairs: &[(String, String)]) -> Value {
914    with_host(|h| {
915        let items: Vec<Value> = pairs
916            .iter()
917            .map(|(k, v)| {
918                let kv = vec![h.new_str(k.clone()), h.new_str(v.clone())];
919                h.new_array(kv)
920            })
921            .collect();
922        let arr = h.new_array(items);
923        let mut m = IndexMap::new();
924        m.insert("@@native".into(), h.new_str("URLSearchParams"));
925        m.insert("@@pairs".into(), arr);
926        // `size` is a prototype getter in the spec; kept in sync as a hidden own
927        // property here, so it reads back without appearing in `Object.keys` or
928        // `console.log`. `set_pairs` maintains it.
929        m.insert("size".into(), Value::Float(pairs.len() as f64));
930        let obj = h.new_object(m);
931        h.hide_prop(&obj, "size");
932        obj
933    })
934}
935
936/// Serialize ordered pairs back into an `application/x-www-form-urlencoded`
937/// query string — the inverse of [`parse_query`].
938fn encode_query(pairs: &[(String, String)]) -> String {
939    pairs
940        .iter()
941        .map(|(k, v)| format!("{}={}", form_encode(k), form_encode(v)))
942        .collect::<Vec<_>>()
943        .join("&")
944}
945
946/// Read the ordered `(key, value)` pairs out of a `URLSearchParams`.
947fn pairs_of(recv: &Value) -> Vec<(String, String)> {
948    with_host(|h| {
949        let items: Vec<Value> = match h.get(recv) {
950            Some(JsObj::Object(p)) => match p.get("@@pairs").and_then(|a| h.get(a)) {
951                Some(JsObj::Array(items)) => items.clone(),
952                _ => Vec::new(),
953            },
954            _ => Vec::new(),
955        };
956        items
957            .iter()
958            .map(|it| match h.get(it) {
959                Some(JsObj::Array(kv)) => {
960                    let kv = kv.clone();
961                    let k = kv.first().map(|x| h.str_of(x)).unwrap_or_default();
962                    let v = kv.get(1).map(|x| h.str_of(x)).unwrap_or_default();
963                    (k, v)
964                }
965                _ => (h.str_of(it), String::new()),
966            })
967            .collect()
968    })
969}
970
971/// Overwrite a `URLSearchParams`' backing `@@pairs` array, and push the new
972/// query back to the `URL` that owns it if there is one.
973///
974/// A `URLSearchParams` reached through `url.searchParams` is LIVE in both
975/// directions: `u.searchParams.set('b', '2')` has to rewrite `u.search` and
976/// `u.href`. It was previously a detached snapshot, so the edit went nowhere.
977fn set_pairs(recv: &Value, pairs: &[(String, String)]) {
978    write_pairs(recv, pairs);
979    let owner = with_host(|h| match h.get(recv) {
980        Some(JsObj::Object(p)) => p.get("@@ownerUrl").cloned(),
981        _ => None,
982    });
983    if let Some(owner) = owner {
984        let query = encode_query(pairs);
985        with_host(|h| {
986            let s = h.new_str(if query.is_empty() {
987                String::new()
988            } else {
989                format!("?{query}")
990            });
991            if let Some(JsObj::Object(p)) = h.get_mut(&owner) {
992                p.insert("@@search".into(), s);
993            }
994        });
995        recompute(&owner, false);
996    }
997}
998
999/// Write `pairs` into a `URLSearchParams` without notifying an owning `URL`.
1000fn write_pairs(recv: &Value, pairs: &[(String, String)]) {
1001    with_host(|h| {
1002        let items: Vec<Value> = pairs
1003            .iter()
1004            .map(|(k, v)| {
1005                let kv = vec![h.new_str(k.clone()), h.new_str(v.clone())];
1006                h.new_array(kv)
1007            })
1008            .collect();
1009        let arr = h.new_array(items);
1010        let n = Value::Float(pairs.len() as f64);
1011        if let Some(JsObj::Object(p)) = h.get_mut(recv) {
1012            p.insert("@@pairs".into(), arr);
1013            p.insert("size".into(), n);
1014        }
1015        h.hide_prop(recv, "size");
1016    });
1017}
1018
1019/// `new URLSearchParams([init])` — from a query string, an object, an iterable of
1020/// `[key, value]` pairs, another `URLSearchParams`, or empty.
1021pub fn construct_search_params(args: &[Value]) -> Result<Value, String> {
1022    let pairs = match args.first() {
1023        None => Vec::new(),
1024        Some(v) if matches!(v, Value::Undef) || with_host(|h| h.is_null(v)) => Vec::new(),
1025        Some(v) => pairs_from_init(v),
1026    };
1027    Ok(make_search_params(&pairs))
1028}
1029
1030fn pairs_from_init(v: &Value) -> Vec<(String, String)> {
1031    // Copy of another URLSearchParams.
1032    if super::native_tag(v).as_deref() == Some("URLSearchParams") {
1033        return pairs_of(v);
1034    }
1035    // Query string (a leading `?` is stripped, matching the URL/WHATWG parser).
1036    if let Some(s) = with_host(|h| h.as_str(v)) {
1037        return parse_query(s.strip_prefix('?').unwrap_or(&s));
1038    }
1039    with_host(|h| match h.get(v) {
1040        // Iterable of `[key, value]` pairs.
1041        Some(JsObj::Array(items)) => {
1042            let items = items.clone();
1043            items
1044                .iter()
1045                .map(|it| match h.get(it) {
1046                    Some(JsObj::Array(kv)) => {
1047                        let kv = kv.clone();
1048                        let k = kv.first().map(|x| h.str_of(x)).unwrap_or_default();
1049                        let val = kv.get(1).map(|x| h.str_of(x)).unwrap_or_default();
1050                        (k, val)
1051                    }
1052                    _ => (h.str_of(it), String::new()),
1053                })
1054                .collect()
1055        }
1056        // Plain object: own enumerable entries (hidden `@@` keys excluded).
1057        Some(JsObj::Object(p)) => {
1058            let entries: Vec<(String, Value)> = p
1059                .iter()
1060                .filter(|(k, _)| !k.starts_with("@@"))
1061                .map(|(k, val)| (k.clone(), val.clone()))
1062                .collect();
1063            entries
1064                .into_iter()
1065                .map(|(k, val)| (k, h.str_of(&val)))
1066                .collect()
1067        }
1068        _ => Vec::new(),
1069    })
1070}
1071
1072/// `URLSearchParams` instance methods.
1073pub fn search_params_call(recv: &Value, method: &str, args: &[Value]) -> Result<Value, String> {
1074    match method {
1075        "get" => {
1076            let name = arg_str(args, 0);
1077            match pairs_of(recv).into_iter().find(|(k, _)| *k == name) {
1078                Some((_, v)) => Ok(with_host(|h| h.new_str(v))),
1079                None => Ok(with_host(|h| h.null())),
1080            }
1081        }
1082        "getAll" => {
1083            let name = arg_str(args, 0);
1084            let vals: Vec<String> = pairs_of(recv)
1085                .into_iter()
1086                .filter(|(k, _)| *k == name)
1087                .map(|(_, v)| v)
1088                .collect();
1089            Ok(with_host(|h| {
1090                let items = vals.into_iter().map(|v| h.new_str(v)).collect();
1091                h.new_array(items)
1092            }))
1093        }
1094        "has" => {
1095            let name = arg_str(args, 0);
1096            let pairs = pairs_of(recv);
1097            let found = if args.len() > 1 {
1098                let val = arg_str(args, 1);
1099                pairs.iter().any(|(k, v)| *k == name && *v == val)
1100            } else {
1101                pairs.iter().any(|(k, _)| *k == name)
1102            };
1103            Ok(Value::Bool(found))
1104        }
1105        "append" => {
1106            let mut pairs = pairs_of(recv);
1107            pairs.push((arg_str(args, 0), arg_str(args, 1)));
1108            set_pairs(recv, &pairs);
1109            Ok(Value::Undef)
1110        }
1111        "set" => {
1112            let name = arg_str(args, 0);
1113            let val = arg_str(args, 1);
1114            let mut pairs = pairs_of(recv);
1115            // Set the first pair named `name` to `val`, remove any others; append
1116            // if none existed (WHATWG `set`).
1117            let mut seen = false;
1118            pairs.retain_mut(|(k, v)| {
1119                if *k == name {
1120                    if seen {
1121                        false
1122                    } else {
1123                        *v = val.clone();
1124                        seen = true;
1125                        true
1126                    }
1127                } else {
1128                    true
1129                }
1130            });
1131            if !seen {
1132                pairs.push((name, val));
1133            }
1134            set_pairs(recv, &pairs);
1135            Ok(Value::Undef)
1136        }
1137        "delete" => {
1138            let name = arg_str(args, 0);
1139            let mut pairs = pairs_of(recv);
1140            if args.len() > 1 {
1141                let val = arg_str(args, 1);
1142                pairs.retain(|(k, v)| !(*k == name && *v == val));
1143            } else {
1144                pairs.retain(|(k, _)| *k != name);
1145            }
1146            set_pairs(recv, &pairs);
1147            Ok(Value::Undef)
1148        }
1149        "sort" => {
1150            let mut pairs = pairs_of(recv);
1151            // Stable sort by key, comparing UTF-16 code units (WHATWG `sort`).
1152            pairs.sort_by(|a, b| a.0.encode_utf16().cmp(b.0.encode_utf16()));
1153            set_pairs(recv, &pairs);
1154            Ok(Value::Undef)
1155        }
1156        "toString" => {
1157            let s = encode_query(&pairs_of(recv));
1158            Ok(with_host(|h| h.new_str(s)))
1159        }
1160        "keys" => {
1161            let pairs = pairs_of(recv);
1162            Ok(with_host(|h| {
1163                let items = pairs.into_iter().map(|(k, _)| h.new_str(k)).collect();
1164                h.alloc(JsObj::Iter {
1165                    items,
1166                    idx: 0,
1167                    array: None,
1168                })
1169            }))
1170        }
1171        "values" => {
1172            let pairs = pairs_of(recv);
1173            Ok(with_host(|h| {
1174                let items = pairs.into_iter().map(|(_, v)| h.new_str(v)).collect();
1175                h.alloc(JsObj::Iter {
1176                    items,
1177                    idx: 0,
1178                    array: None,
1179                })
1180            }))
1181        }
1182        "entries" | "@@iterator" => {
1183            let pairs = pairs_of(recv);
1184            Ok(with_host(|h| {
1185                let items = pairs
1186                    .into_iter()
1187                    .map(|(k, v)| {
1188                        let kv = vec![h.new_str(k), h.new_str(v)];
1189                        h.new_array(kv)
1190                    })
1191                    .collect();
1192                h.alloc(JsObj::Iter {
1193                    items,
1194                    idx: 0,
1195                    array: None,
1196                })
1197            }))
1198        }
1199        "forEach" => {
1200            let cb = args.first().cloned().unwrap_or(Value::Undef);
1201            let this_arg = args.get(1).cloned();
1202            // Materialize pairs (releasing the host borrow) before re-entrant invoke.
1203            for (k, v) in pairs_of(recv) {
1204                let (value, name) = with_host(|h| (h.new_str(v), h.new_str(k)));
1205                crate::host::invoke(&cb, vec![value, name, recv.clone()], this_arg.clone())?;
1206            }
1207            Ok(Value::Undef)
1208        }
1209        _ => Err(crate::host::type_error(&format!(
1210            "urlSearchParams.{method} is not a function"
1211        ))),
1212    }
1213}
1214
1215/// Parse an `application/x-www-form-urlencoded` string into ordered pairs.
1216fn parse_query(q: &str) -> Vec<(String, String)> {
1217    q.split('&')
1218        .filter(|s| !s.is_empty())
1219        .map(|seg| match seg.split_once('=') {
1220            Some((k, v)) => (form_decode(k), form_decode(v)),
1221            None => (form_decode(seg), String::new()),
1222        })
1223        .collect()
1224}
1225
1226/// Decode one `application/x-www-form-urlencoded` component (`+` → space,
1227/// `%XX` → byte, then UTF-8 lossy).
1228fn form_decode(s: &str) -> String {
1229    let b = s.as_bytes();
1230    let mut out: Vec<u8> = Vec::with_capacity(b.len());
1231    let mut i = 0;
1232    while i < b.len() {
1233        match b[i] {
1234            b'+' => {
1235                out.push(b' ');
1236                i += 1;
1237            }
1238            b'%' if i + 2 < b.len() => match (hex_val(b[i + 1]), hex_val(b[i + 2])) {
1239                (Some(hi), Some(lo)) => {
1240                    out.push((hi << 4) | lo);
1241                    i += 3;
1242                }
1243                _ => {
1244                    out.push(b'%');
1245                    i += 1;
1246                }
1247            },
1248            c => {
1249                out.push(c);
1250                i += 1;
1251            }
1252        }
1253    }
1254    String::from_utf8_lossy(&out).into_owned()
1255}
1256
1257/// Encode one `application/x-www-form-urlencoded` component: space → `+`, the
1258/// unreserved set `A-Za-z0-9 * - . _` verbatim, every other byte percent-encoded.
1259fn form_encode(s: &str) -> String {
1260    let mut out = String::with_capacity(s.len());
1261    for &b in s.as_bytes() {
1262        match b {
1263            b' ' => out.push('+'),
1264            b'*' | b'-' | b'.' | b'_' => out.push(b as char),
1265            _ if b.is_ascii_alphanumeric() => out.push(b as char),
1266            _ => {
1267                out.push('%');
1268                out.push(hex_upper(b >> 4));
1269                out.push(hex_upper(b & 0x0f));
1270            }
1271        }
1272    }
1273    out
1274}
1275
1276fn hex_val(c: u8) -> Option<u8> {
1277    match c {
1278        b'0'..=b'9' => Some(c - b'0'),
1279        b'a'..=b'f' => Some(c - b'a' + 10),
1280        b'A'..=b'F' => Some(c - b'A' + 10),
1281        _ => None,
1282    }
1283}
1284
1285fn hex_upper(n: u8) -> char {
1286    char::from_digit(n as u32, 16).unwrap().to_ascii_uppercase()
1287}