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            |_| if port.bytes().all(|b| b == b'0') { Some("0".to_string()) } else { None },
334            |n| Some(n.to_string()),
335        )?
336    } else {
337        return None;
338    };
339
340    let hash = match tail.find('#') {
341        Some(i) => {
342            let h = tail[i..].to_string();
343            tail = &tail[..i];
344            h
345        }
346        None => String::new(),
347    };
348    let search = match tail.find('?') {
349        Some(i) => {
350            let s = tail[i..].to_string();
351            tail = &tail[..i];
352            s
353        }
354        None => String::new(),
355    };
356    // A scheme is case-insensitive and reported lower-case.
357    let scheme = scheme.to_ascii_lowercase();
358    let default_port = special_port(&scheme);
359    let pathname = if tail.is_empty() {
360        "/".to_string()
361    } else {
362        normalize_path(tail)
363    };
364    // The scheme's default port is not part of the serialization.
365    let port = if default_port == Some(port.as_str()) {
366        String::new()
367    } else {
368        port
369    };
370
371    Some(Parts {
372        protocol: format!("{scheme}:"),
373        username,
374        password,
375        hostname,
376        port,
377        pathname,
378        search,
379        hash,
380    })
381}
382
383/// Collapse `.` and `..` segments in an absolute-ish URL path, per the WHATWG
384/// URL path-state machine: `.` drops, `..` pops the previous segment (never past
385/// the root), and a trailing `.`/`..` leaves a trailing slash
386/// (`/a/b/../../../c` → `/c`, `/a/b/..` → `/a/`).
387fn normalize_path(path: &str) -> String {
388    if !path.contains('.') {
389        return path.to_string();
390    }
391    let rooted = path.starts_with('/');
392    let mut out: Vec<&str> = Vec::new();
393    let mut trailing_slash = false;
394    for seg in path.split('/') {
395        match seg {
396            "." => trailing_slash = true,
397            ".." => {
398                out.pop();
399                trailing_slash = true;
400            }
401            _ => {
402                out.push(seg);
403                trailing_slash = false;
404            }
405        }
406    }
407    // `split` on a rooted path yields a leading "" that rebuilds the root slash;
408    // a `..` may have popped it, so restore it.
409    if rooted && out.first() != Some(&"") {
410        out.insert(0, "");
411    }
412    let mut joined = out.join("/");
413    if trailing_slash && !joined.ends_with('/') {
414        joined.push('/');
415    }
416    if joined.is_empty() {
417        joined.push('/');
418    }
419    joined
420}
421
422/// `new URL(input[, base])`.
423pub fn construct(args: &[Value]) -> Result<Value, String> {
424    // Both arguments go through ToString, so an object's own `toString` is
425    // what gets parsed (`new URL('x', { toString() { return 'http://a/' } })`).
426    let to_str = |v: &Value| {
427        crate::host::to_string_value(v).map(|s| crate::host::with_host(|h| h.str_of(&s)))
428    };
429    let input = match args.first() {
430        Some(v) => to_str(v)?,
431        None => "undefined".to_string(),
432    };
433    // An explicit `undefined` base is no base at all.
434    let base = match args.get(1) {
435        Some(Value::Undef) | None => None,
436        Some(v) => Some(to_str(v)?),
437    };
438    let parts = parse_absolute(&input)
439        .or_else(|| {
440            // A base makes a relative input absolute (path replacement only).
441            if let Some(base) = &base {
442                parse_absolute(base).map(|mut b| {
443                    // Split the RELATIVE reference's own query/fragment off first;
444                    // they replace the base's, they do not append to its path.
445                    let mut rest = input.as_str();
446                    let hash = match rest.find('#') {
447                        Some(i) => {
448                            let h = rest[i..].to_string();
449                            rest = &rest[..i];
450                            h
451                        }
452                        None => String::new(),
453                    };
454                    let search = match rest.find('?') {
455                        Some(i) => {
456                            let q = rest[i..].to_string();
457                            rest = &rest[..i];
458                            q
459                        }
460                        None => String::new(),
461                    };
462                    // A rooted reference replaces the path; anything else resolves
463                    // against the base's DIRECTORY (everything up to its last `/`).
464                    let merged = if rest.starts_with('/') {
465                        rest.to_string()
466                    } else if rest.is_empty() {
467                        b.pathname.clone()
468                    } else {
469                        let dir = match b.pathname.rfind('/') {
470                            Some(i) => &b.pathname[..=i],
471                            None => "/",
472                        };
473                        format!("{dir}{rest}")
474                    };
475                    b.pathname = normalize_path(&merged);
476                    b.search = search;
477                    b.hash = hash;
478                    b
479                })
480            } else {
481                None
482            }
483        })
484        // Node's message is the bare `Invalid URL` and it carries
485        // `code === 'ERR_INVALID_URL'`; the input is exposed as `err.input`, not
486        // appended to the text. `url_legacy::invalid_url` was already emitting
487        // the current form — this site was the one still hardcoding an older one.
488        // node also hangs the input (and the base, when one was passed) off the
489        // error as `err.input` / `err.base`.
490        .ok_or_else(|| {
491            let mut fields = vec![("input", input.as_str())];
492            if let Some(b) = &base {
493                fields.push(("base", b.as_str()));
494            }
495            crate::host::plain_coded_error_with("TypeError", "ERR_INVALID_URL", "Invalid URL", &fields)
496        })?;
497    Ok(build(&parts))
498}
499
500/// Percent-encode `s` for one URL component, per the WHATWG percent-encode sets.
501///
502/// None of this was happening: `new URL('https://a.b/a b?c=d e').href` came back
503/// with the spaces intact, which is not a valid URL and does not round-trip.
504///
505/// The sets below were derived by feeding every ASCII character through node
506/// v26.8.1 in each position rather than transcribed, since the spec's sets and
507/// what a parser actually emits differ around the component delimiters. Every
508/// C0 control, `%7F`, and every non-ASCII byte is encoded in all four; a byte
509/// already part of a valid `%XX` escape is left alone so re-parsing a URL does
510/// not double-encode it.
511fn percent_encode(s: &str, extra: &str) -> String {
512    let bytes = s.as_bytes();
513    let mut out = String::with_capacity(s.len());
514    let mut i = 0;
515    while i < bytes.len() {
516        let b = bytes[i];
517        // An existing escape passes through untouched.
518        if b == b'%' && i + 2 < bytes.len() + 1 {
519            let hex = bytes.get(i + 1..i + 3);
520            if hex.is_some_and(|h| h.iter().all(|c| c.is_ascii_hexdigit())) {
521                out.push('%');
522                out.push(bytes[i + 1] as char);
523                out.push(bytes[i + 2] as char);
524                i += 3;
525                continue;
526            }
527        }
528        if b < 0x20 || b == 0x7f || b >= 0x80 || extra.as_bytes().contains(&b) {
529            out.push_str(&format!("%{b:02X}"));
530        } else {
531            out.push(b as char);
532        }
533        i += 1;
534    }
535    out
536}
537
538/// The four component encode sets, as measured against node.
539const PATH_SET: &str = " \"<>^`{}";
540const QUERY_SET: &str = " \"'<>";
541const FRAGMENT_SET: &str = " \"<>`";
542const USERINFO_SET: &str = " \";<=>@[]^`{|}";
543
544fn build(p: &Parts) -> Value {
545    // Percent-encode each component once, here, so `href()` and every
546    // individual property report the same normalized text. The host arrives
547    // already canonical from the host parser in `parse_absolute`; lower-casing
548    // it again here also folded a non-special scheme's opaque host, which
549    // node keeps as written (`foo://Host/`).
550    let p = &Parts {
551        protocol: p.protocol.clone(),
552        username: percent_encode(&p.username, USERINFO_SET),
553        password: percent_encode(&p.password, USERINFO_SET),
554        hostname: p.hostname.clone(),
555        port: p.port.clone(),
556        pathname: percent_encode(&p.pathname, PATH_SET),
557        search: percent_encode(&p.search, QUERY_SET),
558        hash: percent_encode(&p.hash, FRAGMENT_SET),
559    };
560    // Build the `URLSearchParams` BEFORE the allocating `with_host` below (never
561    // nest `with_host`); it is stored as the `searchParams` data property so
562    // `url.searchParams.get(...)` reads it directly. It is LIVE, not a snapshot:
563    // it gets an `@@ownerUrl` back-reference below so that mutating it rewrites
564    // this URL's `search` and `href`.
565    let query = p.search.strip_prefix('?').unwrap_or(&p.search);
566    let search_params = make_search_params(&parse_query(query));
567    with_host(|h| {
568        let mut m = IndexMap::new();
569        m.insert("@@native".into(), h.new_str("URL"));
570        m.insert("@@href".into(), h.new_str(p.href()));
571        m.insert("@@origin".into(), h.new_str(p.origin()));
572        m.insert("@@protocol".into(), h.new_str(p.protocol.clone()));
573        m.insert("@@username".into(), h.new_str(p.username.clone()));
574        m.insert("@@password".into(), h.new_str(p.password.clone()));
575        m.insert("@@host".into(), h.new_str(p.host()));
576        m.insert("@@hostname".into(), h.new_str(p.hostname.clone()));
577        m.insert("@@port".into(), h.new_str(p.port.clone()));
578        m.insert("@@pathname".into(), h.new_str(p.pathname.clone()));
579        m.insert("@@search".into(), h.new_str(p.search.clone()));
580        m.insert("@@searchParams".into(), search_params.clone());
581        m.insert("@@hash".into(), h.new_str(p.hash.clone()));
582        let obj = h.new_object(m);
583        // Hidden, and set after the URL exists so the two can point at each other.
584        if let Some(JsObj::Object(sp)) = h.get_mut(&search_params) {
585            sp.insert("@@ownerUrl".into(), obj.clone());
586        }
587        obj
588    })
589}
590
591/// Statics on the `URL` CLASS — distinct from [`MODULE_METHODS`], which are the
592/// legacy `require('url')` functions.
593///
594/// `createObjectURL`/`revokeObjectURL` are absent because `Blob` is not
595/// implemented; they would have nothing to register.
596pub const STATIC_METHODS: &[&str] = &["canParse", "parse"];
597
598/// `URL.canParse(input[, base])` / `URL.parse(input[, base])`.
599///
600/// Both are the non-throwing form of the constructor: `canParse` reports
601/// whether parsing succeeds, `parse` returns the `URL` or `null`. Neither
602/// existed, so `URL.canParse` was a TypeError rather than a boolean.
603pub fn static_call(method: &str, args: &[Value]) -> Option<Result<Value, String>> {
604    let parsed = construct(args);
605    Some(match method {
606        "canParse" => Ok(Value::Bool(parsed.is_ok())),
607        "parse" => Ok(parsed.unwrap_or_else(|_| with_host(|h| h.null()))),
608        _ => return None,
609    })
610}
611
612pub fn call(method: &str, args: &[Value]) -> Option<Result<Value, String>> {
613    Some(match method {
614        "parse" => legacy_parse(args).map(|u| super::url_legacy::to_js(&u)),
615        "format" => super::url_legacy::format_value(&args.first().cloned().unwrap_or(Value::Undef)),
616        // `url.fileURLToPath(url)` — a `file:` URL/string → a filesystem path
617        // (percent-decoded). POSIX best-effort: any authority (host) is accepted
618        // but not re-prefixed; Windows drive/UNC rewriting is not modeled.
619        "fileURLToPath" => file_url_to_path(args).map(|s| with_host(|h| h.new_str(s))),
620        // Same, but returns the path as a `Buffer`.
621        "fileURLToPathBuffer" => {
622            file_url_to_path(args).map(|s| super::buffer::from_bytes(s.as_bytes()))
623        }
624        // `url.pathToFileURL(path)` → a `URL` instance with a `file:` href.
625        "pathToFileURL" => Ok(path_to_file_url(&arg_str(args, 0))),
626        // `url.domainToASCII` / `url.domainToUnicode` — delegate to the punycode
627        // codec; an ASCII-only domain passes through unchanged, an invalid domain
628        // yields "" (matching Node, which never throws here).
629        "domainToASCII" => Ok(punycode_domain(args, true)),
630        "domainToUnicode" => Ok(punycode_domain(args, false)),
631        // `url.urlToHttpOptions(URL)` → an options object for http/https.request.
632        "urlToHttpOptions" => Ok(url_to_http_options(
633            &args.first().cloned().unwrap_or(Value::Undef),
634        )),
635        // Legacy `url.resolve(from, to)` — `urlParse(from, false, true)
636        // .resolve(to)`: both sides parsed with `slashesDenoteHost`, resolved by
637        // the `Url.prototype.resolveObject` port, then formatted.
638        "resolve" => legacy_resolve_object(args)
639            .map(|u| with_host(|h| h.new_str(u.href.unwrap_or_default()))),
640        // Legacy `url.resolveObject(from, to)` — the same resolution, returned
641        // as the parsed object. An empty `from` hands `to` back untouched.
642        "resolveObject" => {
643            if !args.first().is_some_and(|v| with_host(|h| h.truthy(v))) {
644                return Some(Ok(args.get(1).cloned().unwrap_or(Value::Undef)));
645            }
646            legacy_resolve_object(args).map(|u| super::url_legacy::to_js(&u))
647        }
648        _ => return None,
649    })
650}
651
652/// Legacy `url.parse(urlString[, parseQueryString[, slashesDenoteHost]])`.
653/// Emits the one-shot `DEP0169` deprecation warning, exactly as Node's
654/// `urlParse` does, then delegates to the `Url.prototype.parse` port.
655fn legacy_parse(args: &[Value]) -> Result<super::url_legacy::Url, String> {
656    emit_url_parse_deprecation();
657    let input = arg_str(args, 0);
658    let truthy = |i: usize| {
659        args.get(i)
660            .map(|v| with_host(|h| h.truthy(v)))
661            .unwrap_or(false)
662    };
663    super::url_legacy::parse(&input, truthy(1), truthy(2))
664}
665
666/// `urlParse`'s one-time `DEP0169`, shared by `parse`, `resolve` and
667/// `resolveObject` — all three go through `urlParse` in node.
668fn emit_url_parse_deprecation() {
669    super::process::emit_deprecation_warning(
670        "DEP0169",
671        "`url.parse()` behavior is not standardized and prone to errors that \
672         have security implications. Use the WHATWG URL API instead. CVEs are \
673         not issued for `url.parse()` vulnerabilities.",
674    );
675}
676
677/// `urlParse(args[0], false, true).resolveObject(args[1])`, emitting the
678/// one-shot `DEP0169` that `urlParse` raises.
679fn legacy_resolve_object(args: &[Value]) -> Result<super::url_legacy::Url, String> {
680    emit_url_parse_deprecation();
681    let source = super::url_legacy::parse(&arg_str(args, 0), false, true)?;
682    let relative = super::url_legacy::parse(&arg_str(args, 1), false, true)?;
683    Ok(super::url_legacy::resolve_object(&source, relative))
684}
685
686/// `URL` instance methods (component reads are plain data properties).
687pub fn instance_call(recv: &Value, method: &str, _args: &[Value]) -> Result<Value, String> {
688    match method {
689        "toString" | "toJSON" => Ok(with_host(|h| match h.get(recv) {
690            Some(JsObj::Object(p)) => p.get("@@href").cloned().unwrap_or(Value::Undef),
691            _ => Value::Undef,
692        })),
693        _ => Err(crate::host::type_error(&format!(
694            "url.{method} is not a function"
695        ))),
696    }
697}
698
699// ── file:/legacy URL helpers ─────────────────────────────────────────────────
700
701/// The `href` string of a value: for a native `URL` its stored `href`, else the
702/// value coerced to a string (so both `URL` objects and strings are accepted).
703fn url_href(v: &Value) -> String {
704    with_host(|h| match h.get(v) {
705        Some(JsObj::Object(p)) => match p.get("@@native").map(|x| h.str_of(x)).as_deref() {
706            Some("URL") => p.get("@@href").map(|x| h.str_of(x)).unwrap_or_default(),
707            _ => h.str_of(v),
708        },
709        _ => h.str_of(v),
710    })
711}
712
713/// `fileURLToPath` core: `file://[host]/path` → decoded `/path`.
714fn file_url_to_path(args: &[Value]) -> Result<String, String> {
715    let v = args.first().cloned().unwrap_or(Value::Undef);
716    let href = url_href(&v);
717    let rest = href.strip_prefix("file://").ok_or_else(|| {
718        crate::host::plain_coded_error(
719            "TypeError",
720            "ERR_INVALID_URL_SCHEME",
721            "The URL must be of scheme file",
722        )
723    })?;
724    // The authority runs up to the first '/'; the remainder is the path.
725    let path = match rest.find('/') {
726        Some(0) => rest,
727        Some(i) => &rest[i..],
728        None => "/",
729    };
730    Ok(percent_decode(path))
731}
732
733/// `pathToFileURL(path)` → a `URL` instance whose href is `file://` + the
734/// percent-encoded (path-set) path.
735fn path_to_file_url(path: &str) -> Value {
736    let enc = encode_path_component(path);
737    let pathname = if enc.starts_with('/') {
738        enc
739    } else {
740        format!("/{enc}")
741    };
742    let parts = Parts {
743        protocol: "file:".into(),
744        username: String::new(),
745        password: String::new(),
746        hostname: String::new(),
747        port: String::new(),
748        pathname,
749        search: String::new(),
750        hash: String::new(),
751    };
752    build(&parts)
753}
754
755/// `domainToASCII` (`ascii = true`) / `domainToUnicode` — via the punycode codec.
756fn punycode_domain(args: &[Value], ascii: bool) -> Value {
757    let method = if ascii { "toASCII" } else { "toUnicode" };
758    match super::punycode::call(method, args) {
759        Some(Ok(v)) => v,
760        _ => with_host(|h| h.new_str("")),
761    }
762}
763
764/// `urlToHttpOptions(URL)` → `{ protocol, hostname, hash, search, pathname, path,
765/// href[, port][, auth] }`, mirroring Node's field set and IPv6 bracket-stripping.
766fn url_to_http_options(v: &Value) -> Value {
767    let get = |key: &str| -> String {
768        with_host(|h| match h.get(v) {
769            Some(JsObj::Object(p)) => p.get(key).map(|x| h.str_of(x)).unwrap_or_default(),
770            _ => String::new(),
771        })
772    };
773    let protocol = get("@@protocol");
774    let mut hostname = get("@@hostname");
775    if hostname.starts_with('[') && hostname.ends_with(']') && hostname.len() >= 2 {
776        hostname = hostname[1..hostname.len() - 1].to_string();
777    }
778    let hash = get("@@hash");
779    let search = get("@@search");
780    let pathname = get("@@pathname");
781    let href = get("@@href");
782    let port = get("@@port");
783    let username = get("@@username");
784    let password = get("@@password");
785    let path = format!("{pathname}{search}");
786    let auth = if username.is_empty() && password.is_empty() {
787        None
788    } else {
789        Some(format!(
790            "{}:{}",
791            percent_decode(&username),
792            percent_decode(&password)
793        ))
794    };
795    let port_num = if port.is_empty() {
796        None
797    } else {
798        port.parse::<f64>().ok()
799    };
800    with_host(|h| {
801        let mut m = IndexMap::new();
802        m.insert("protocol".into(), h.new_str(protocol));
803        m.insert("hostname".into(), h.new_str(hostname));
804        m.insert("hash".into(), h.new_str(hash));
805        m.insert("search".into(), h.new_str(search));
806        m.insert("pathname".into(), h.new_str(pathname));
807        m.insert("path".into(), h.new_str(path));
808        m.insert("href".into(), h.new_str(href));
809        if let Some(n) = port_num {
810            m.insert("port".into(), Value::Float(n));
811        }
812        if let Some(a) = auth {
813            m.insert("auth".into(), h.new_str(a));
814        }
815        h.new_object(m)
816    })
817}
818
819/// Percent-decode a URL component (`%XX` → byte, then UTF-8 lossy). Unlike the
820/// form decoder this leaves `+` literal (a file path may legitimately contain it).
821pub(crate) fn percent_decode(s: &str) -> String {
822    let b = s.as_bytes();
823    let mut out: Vec<u8> = Vec::with_capacity(b.len());
824    let mut i = 0;
825    while i < b.len() {
826        if b[i] == b'%' && i + 2 < b.len() {
827            if let (Some(hi), Some(lo)) = (hex_val(b[i + 1]), hex_val(b[i + 2])) {
828                out.push((hi << 4) | lo);
829                i += 3;
830                continue;
831            }
832        }
833        out.push(b[i]);
834        i += 1;
835    }
836    String::from_utf8_lossy(&out).into_owned()
837}
838
839/// Percent-encode a path for a `file:` URL: keep the unreserved + sub-delim set
840/// and `/ : @`, encode everything else (space, `# ? %` `< > "` etc.).
841fn encode_path_component(s: &str) -> String {
842    let mut out = String::with_capacity(s.len());
843    for &b in s.as_bytes() {
844        let keep = b.is_ascii_alphanumeric()
845            || matches!(
846                b,
847                b'/' | b'-'
848                    | b'.'
849                    | b'_'
850                    | b'~'
851                    | b'!'
852                    | b'$'
853                    | b'&'
854                    | b'\''
855                    | b'('
856                    | b')'
857                    | b'*'
858                    | b'+'
859                    | b','
860                    | b';'
861                    | b'='
862                    | b':'
863                    | b'@'
864            );
865        if keep {
866            out.push(b as char);
867        } else {
868            out.push('%');
869            out.push(hex_upper(b >> 4));
870            out.push(hex_upper(b & 0x0f));
871        }
872    }
873    out
874}
875
876// ── URLSearchParams ──────────────────────────────────────────────────────────
877//
878// A `URLSearchParams` is a plain object tagged `@@native = "URLSearchParams"`
879// whose ordered `[key, value]` pairs live in a hidden `@@pairs` array (each entry
880// a 2-element `[key, value]` array of strings). All string coercion happens up
881// front; methods mutate a plain `Vec<(String, String)>` and write it back.
882
883/// Method names dispatched through `search_params_call` (for `instance_has_method`
884/// wiring in `stdlib::mod`; `@@iterator` makes `[...params]` / `for..of` work).
885pub const SEARCH_PARAMS_METHODS: &[&str] = &[
886    "get",
887    "getAll",
888    "has",
889    "set",
890    "append",
891    "delete",
892    "keys",
893    "values",
894    "entries",
895    "forEach",
896    "toString",
897    "sort",
898    "@@iterator",
899];
900
901/// Build a `URLSearchParams` native object from ordered key/value pairs.
902fn make_search_params(pairs: &[(String, String)]) -> Value {
903    with_host(|h| {
904        let items: Vec<Value> = pairs
905            .iter()
906            .map(|(k, v)| {
907                let kv = vec![h.new_str(k.clone()), h.new_str(v.clone())];
908                h.new_array(kv)
909            })
910            .collect();
911        let arr = h.new_array(items);
912        let mut m = IndexMap::new();
913        m.insert("@@native".into(), h.new_str("URLSearchParams"));
914        m.insert("@@pairs".into(), arr);
915        // `size` is a prototype getter in the spec; kept in sync as a hidden own
916        // property here, so it reads back without appearing in `Object.keys` or
917        // `console.log`. `set_pairs` maintains it.
918        m.insert("size".into(), Value::Float(pairs.len() as f64));
919        let obj = h.new_object(m);
920        h.hide_prop(&obj, "size");
921        obj
922    })
923}
924
925/// Serialize ordered pairs back into an `application/x-www-form-urlencoded`
926/// query string — the inverse of [`parse_query`].
927fn encode_query(pairs: &[(String, String)]) -> String {
928    pairs
929        .iter()
930        .map(|(k, v)| format!("{}={}", form_encode(k), form_encode(v)))
931        .collect::<Vec<_>>()
932        .join("&")
933}
934
935/// Read the ordered `(key, value)` pairs out of a `URLSearchParams`.
936fn pairs_of(recv: &Value) -> Vec<(String, String)> {
937    with_host(|h| {
938        let items: Vec<Value> = match h.get(recv) {
939            Some(JsObj::Object(p)) => match p.get("@@pairs").and_then(|a| h.get(a)) {
940                Some(JsObj::Array(items)) => items.clone(),
941                _ => Vec::new(),
942            },
943            _ => Vec::new(),
944        };
945        items
946            .iter()
947            .map(|it| match h.get(it) {
948                Some(JsObj::Array(kv)) => {
949                    let kv = kv.clone();
950                    let k = kv.first().map(|x| h.str_of(x)).unwrap_or_default();
951                    let v = kv.get(1).map(|x| h.str_of(x)).unwrap_or_default();
952                    (k, v)
953                }
954                _ => (h.str_of(it), String::new()),
955            })
956            .collect()
957    })
958}
959
960/// Overwrite a `URLSearchParams`' backing `@@pairs` array, and push the new
961/// query back to the `URL` that owns it if there is one.
962///
963/// A `URLSearchParams` reached through `url.searchParams` is LIVE in both
964/// directions: `u.searchParams.set('b', '2')` has to rewrite `u.search` and
965/// `u.href`. It was previously a detached snapshot, so the edit went nowhere.
966fn set_pairs(recv: &Value, pairs: &[(String, String)]) {
967    write_pairs(recv, pairs);
968    let owner = with_host(|h| match h.get(recv) {
969        Some(JsObj::Object(p)) => p.get("@@ownerUrl").cloned(),
970        _ => None,
971    });
972    if let Some(owner) = owner {
973        let query = encode_query(pairs);
974        with_host(|h| {
975            let s = h.new_str(if query.is_empty() {
976                String::new()
977            } else {
978                format!("?{query}")
979            });
980            if let Some(JsObj::Object(p)) = h.get_mut(&owner) {
981                p.insert("@@search".into(), s);
982            }
983        });
984        recompute(&owner, false);
985    }
986}
987
988/// Write `pairs` into a `URLSearchParams` without notifying an owning `URL`.
989fn write_pairs(recv: &Value, pairs: &[(String, String)]) {
990    with_host(|h| {
991        let items: Vec<Value> = pairs
992            .iter()
993            .map(|(k, v)| {
994                let kv = vec![h.new_str(k.clone()), h.new_str(v.clone())];
995                h.new_array(kv)
996            })
997            .collect();
998        let arr = h.new_array(items);
999        let n = Value::Float(pairs.len() as f64);
1000        if let Some(JsObj::Object(p)) = h.get_mut(recv) {
1001            p.insert("@@pairs".into(), arr);
1002            p.insert("size".into(), n);
1003        }
1004        h.hide_prop(recv, "size");
1005    });
1006}
1007
1008/// `new URLSearchParams([init])` — from a query string, an object, an iterable of
1009/// `[key, value]` pairs, another `URLSearchParams`, or empty.
1010pub fn construct_search_params(args: &[Value]) -> Result<Value, String> {
1011    let pairs = match args.first() {
1012        None => Vec::new(),
1013        Some(v) if matches!(v, Value::Undef) || with_host(|h| h.is_null(v)) => Vec::new(),
1014        Some(v) => pairs_from_init(v),
1015    };
1016    Ok(make_search_params(&pairs))
1017}
1018
1019fn pairs_from_init(v: &Value) -> Vec<(String, String)> {
1020    // Copy of another URLSearchParams.
1021    if super::native_tag(v).as_deref() == Some("URLSearchParams") {
1022        return pairs_of(v);
1023    }
1024    // Query string (a leading `?` is stripped, matching the URL/WHATWG parser).
1025    if let Some(s) = with_host(|h| h.as_str(v)) {
1026        return parse_query(s.strip_prefix('?').unwrap_or(&s));
1027    }
1028    with_host(|h| match h.get(v) {
1029        // Iterable of `[key, value]` pairs.
1030        Some(JsObj::Array(items)) => {
1031            let items = items.clone();
1032            items
1033                .iter()
1034                .map(|it| match h.get(it) {
1035                    Some(JsObj::Array(kv)) => {
1036                        let kv = kv.clone();
1037                        let k = kv.first().map(|x| h.str_of(x)).unwrap_or_default();
1038                        let val = kv.get(1).map(|x| h.str_of(x)).unwrap_or_default();
1039                        (k, val)
1040                    }
1041                    _ => (h.str_of(it), String::new()),
1042                })
1043                .collect()
1044        }
1045        // Plain object: own enumerable entries (hidden `@@` keys excluded).
1046        Some(JsObj::Object(p)) => {
1047            let entries: Vec<(String, Value)> = p
1048                .iter()
1049                .filter(|(k, _)| !k.starts_with("@@"))
1050                .map(|(k, val)| (k.clone(), val.clone()))
1051                .collect();
1052            entries
1053                .into_iter()
1054                .map(|(k, val)| (k, h.str_of(&val)))
1055                .collect()
1056        }
1057        _ => Vec::new(),
1058    })
1059}
1060
1061/// `URLSearchParams` instance methods.
1062pub fn search_params_call(recv: &Value, method: &str, args: &[Value]) -> Result<Value, String> {
1063    match method {
1064        "get" => {
1065            let name = arg_str(args, 0);
1066            match pairs_of(recv).into_iter().find(|(k, _)| *k == name) {
1067                Some((_, v)) => Ok(with_host(|h| h.new_str(v))),
1068                None => Ok(with_host(|h| h.null())),
1069            }
1070        }
1071        "getAll" => {
1072            let name = arg_str(args, 0);
1073            let vals: Vec<String> = pairs_of(recv)
1074                .into_iter()
1075                .filter(|(k, _)| *k == name)
1076                .map(|(_, v)| v)
1077                .collect();
1078            Ok(with_host(|h| {
1079                let items = vals.into_iter().map(|v| h.new_str(v)).collect();
1080                h.new_array(items)
1081            }))
1082        }
1083        "has" => {
1084            let name = arg_str(args, 0);
1085            let pairs = pairs_of(recv);
1086            let found = if args.len() > 1 {
1087                let val = arg_str(args, 1);
1088                pairs.iter().any(|(k, v)| *k == name && *v == val)
1089            } else {
1090                pairs.iter().any(|(k, _)| *k == name)
1091            };
1092            Ok(Value::Bool(found))
1093        }
1094        "append" => {
1095            let mut pairs = pairs_of(recv);
1096            pairs.push((arg_str(args, 0), arg_str(args, 1)));
1097            set_pairs(recv, &pairs);
1098            Ok(Value::Undef)
1099        }
1100        "set" => {
1101            let name = arg_str(args, 0);
1102            let val = arg_str(args, 1);
1103            let mut pairs = pairs_of(recv);
1104            // Set the first pair named `name` to `val`, remove any others; append
1105            // if none existed (WHATWG `set`).
1106            let mut seen = false;
1107            pairs.retain_mut(|(k, v)| {
1108                if *k == name {
1109                    if seen {
1110                        false
1111                    } else {
1112                        *v = val.clone();
1113                        seen = true;
1114                        true
1115                    }
1116                } else {
1117                    true
1118                }
1119            });
1120            if !seen {
1121                pairs.push((name, val));
1122            }
1123            set_pairs(recv, &pairs);
1124            Ok(Value::Undef)
1125        }
1126        "delete" => {
1127            let name = arg_str(args, 0);
1128            let mut pairs = pairs_of(recv);
1129            if args.len() > 1 {
1130                let val = arg_str(args, 1);
1131                pairs.retain(|(k, v)| !(*k == name && *v == val));
1132            } else {
1133                pairs.retain(|(k, _)| *k != name);
1134            }
1135            set_pairs(recv, &pairs);
1136            Ok(Value::Undef)
1137        }
1138        "sort" => {
1139            let mut pairs = pairs_of(recv);
1140            // Stable sort by key, comparing UTF-16 code units (WHATWG `sort`).
1141            pairs.sort_by(|a, b| a.0.encode_utf16().cmp(b.0.encode_utf16()));
1142            set_pairs(recv, &pairs);
1143            Ok(Value::Undef)
1144        }
1145        "toString" => {
1146            let s = encode_query(&pairs_of(recv));
1147            Ok(with_host(|h| h.new_str(s)))
1148        }
1149        "keys" => {
1150            let pairs = pairs_of(recv);
1151            Ok(with_host(|h| {
1152                let items = pairs.into_iter().map(|(k, _)| h.new_str(k)).collect();
1153                h.alloc(JsObj::Iter { items, idx: 0 })
1154            }))
1155        }
1156        "values" => {
1157            let pairs = pairs_of(recv);
1158            Ok(with_host(|h| {
1159                let items = pairs.into_iter().map(|(_, v)| h.new_str(v)).collect();
1160                h.alloc(JsObj::Iter { items, idx: 0 })
1161            }))
1162        }
1163        "entries" | "@@iterator" => {
1164            let pairs = pairs_of(recv);
1165            Ok(with_host(|h| {
1166                let items = pairs
1167                    .into_iter()
1168                    .map(|(k, v)| {
1169                        let kv = vec![h.new_str(k), h.new_str(v)];
1170                        h.new_array(kv)
1171                    })
1172                    .collect();
1173                h.alloc(JsObj::Iter { items, idx: 0 })
1174            }))
1175        }
1176        "forEach" => {
1177            let cb = args.first().cloned().unwrap_or(Value::Undef);
1178            let this_arg = args.get(1).cloned();
1179            // Materialize pairs (releasing the host borrow) before re-entrant invoke.
1180            for (k, v) in pairs_of(recv) {
1181                let (value, name) = with_host(|h| (h.new_str(v), h.new_str(k)));
1182                crate::host::invoke(&cb, vec![value, name, recv.clone()], this_arg.clone())?;
1183            }
1184            Ok(Value::Undef)
1185        }
1186        _ => Err(crate::host::type_error(&format!(
1187            "urlSearchParams.{method} is not a function"
1188        ))),
1189    }
1190}
1191
1192/// Parse an `application/x-www-form-urlencoded` string into ordered pairs.
1193fn parse_query(q: &str) -> Vec<(String, String)> {
1194    q.split('&')
1195        .filter(|s| !s.is_empty())
1196        .map(|seg| match seg.split_once('=') {
1197            Some((k, v)) => (form_decode(k), form_decode(v)),
1198            None => (form_decode(seg), String::new()),
1199        })
1200        .collect()
1201}
1202
1203/// Decode one `application/x-www-form-urlencoded` component (`+` → space,
1204/// `%XX` → byte, then UTF-8 lossy).
1205fn form_decode(s: &str) -> String {
1206    let b = s.as_bytes();
1207    let mut out: Vec<u8> = Vec::with_capacity(b.len());
1208    let mut i = 0;
1209    while i < b.len() {
1210        match b[i] {
1211            b'+' => {
1212                out.push(b' ');
1213                i += 1;
1214            }
1215            b'%' if i + 2 < b.len() => match (hex_val(b[i + 1]), hex_val(b[i + 2])) {
1216                (Some(hi), Some(lo)) => {
1217                    out.push((hi << 4) | lo);
1218                    i += 3;
1219                }
1220                _ => {
1221                    out.push(b'%');
1222                    i += 1;
1223                }
1224            },
1225            c => {
1226                out.push(c);
1227                i += 1;
1228            }
1229        }
1230    }
1231    String::from_utf8_lossy(&out).into_owned()
1232}
1233
1234/// Encode one `application/x-www-form-urlencoded` component: space → `+`, the
1235/// unreserved set `A-Za-z0-9 * - . _` verbatim, every other byte percent-encoded.
1236fn form_encode(s: &str) -> String {
1237    let mut out = String::with_capacity(s.len());
1238    for &b in s.as_bytes() {
1239        match b {
1240            b' ' => out.push('+'),
1241            b'*' | b'-' | b'.' | b'_' => out.push(b as char),
1242            _ if b.is_ascii_alphanumeric() => out.push(b as char),
1243            _ => {
1244                out.push('%');
1245                out.push(hex_upper(b >> 4));
1246                out.push(hex_upper(b & 0x0f));
1247            }
1248        }
1249    }
1250    out
1251}
1252
1253fn hex_val(c: u8) -> Option<u8> {
1254    match c {
1255        b'0'..=b'9' => Some(c - b'0'),
1256        b'a'..=b'f' => Some(c - b'a' + 10),
1257        b'A'..=b'F' => Some(c - b'A' + 10),
1258        _ => None,
1259    }
1260}
1261
1262fn hex_upper(n: u8) -> char {
1263    char::from_digit(n as u32, 16).unwrap().to_ascii_uppercase()
1264}