Skip to main content

ferrijs_std/url/
mod.rs

1// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2// SPDX-License-Identifier: Apache-2.0
3#![allow(clippy::inherent_to_string)]
4pub mod url_class;
5pub mod url_search_params;
6
7use std::{path::PathBuf, str::FromStr};
8
9use crate::utils::{
10    module::{export_default, ModuleInfo},
11    primordials::{BasePrimordials, Primordial},
12    result::ResultExt,
13};
14use rquickjs::{
15    function::{Constructor, Func},
16    module::{Declarations, Exports, ModuleDef},
17    prelude::Opt,
18    Class, Coerced, Ctx, Exception, Result, Value,
19};
20use url::{quirks, Url};
21
22use self::url_class::{url_to_http_options, URL};
23use self::url_search_params::URLSearchParams;
24
25/// Returns whether the given scheme is a [special scheme](https://url.spec.whatwg.org/#special-scheme).
26pub fn is_special_scheme(scheme: &str) -> bool {
27    matches!(scheme, "http" | "https" | "ftp" | "ws" | "wss" | "file")
28}
29
30pub fn domain_to_unicode(domain: &str) -> String {
31    quirks::domain_to_unicode(domain)
32}
33
34pub fn domain_to_ascii(domain: &str) -> String {
35    quirks::domain_to_ascii(domain)
36}
37
38//options are ignored, no windows support yet
39pub fn path_to_file_url<'js>(ctx: Ctx<'js>, path: String, _: Opt<Value>) -> Result<URL<'js>> {
40    let url = Url::from_file_path(&path)
41        .map_err(|_| Exception::throw_type(&ctx, &["Path is not absolute: ", &path].concat()))?;
42
43    URL::from_url(ctx, url)
44}
45
46//options are ignored, no windows support yet
47pub fn file_url_to_path<'js>(ctx: Ctx<'js>, url: Value<'js>) -> Result<String> {
48    let url_string = if let Ok(url) = Class::<URL>::from_value(&url) {
49        url.borrow().to_string()
50    } else {
51        url.get::<Coerced<String>>()?.to_string()
52    };
53
54    // Node checks the scheme, refuses a host it cannot address locally,
55    // drops the query and fragment, and PERCENT-DECODES the path — an
56    // undecoded `%20` reaches the filesystem as three literal
57    // characters, so a path with a space silently fails to open.
58    let rest = url_string
59        .strip_prefix("file://")
60        .ok_or_else(|| Exception::throw_type(&ctx, "The URL must be of scheme file"))?;
61    let path = match rest.find('/') {
62        Some(0) => rest,
63        _ => return Err(Exception::throw_type(&ctx, "File URL host is not supported")),
64    };
65    let path = path.split(['?', '#']).next().unwrap_or(path);
66    let decoded = decode_file_url_path(&ctx, path)?;
67
68    Ok(PathBuf::from_str(&decoded)
69        .or_throw(&ctx)?
70        .to_string_lossy()
71        .to_string())
72}
73
74/// Percent-decode a `file:` URL's path. An ENCODED separator is
75/// refused rather than decoded: `%2F` inside a segment would otherwise
76/// become a path separator and change which file is named.
77fn decode_file_url_path(ctx: &Ctx<'_>, path: &str) -> Result<String> {
78    let bytes = path.as_bytes();
79    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
80    let mut i = 0;
81    while i < bytes.len() {
82        if bytes[i] == b'%' && i + 2 < bytes.len() {
83            let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or("");
84            match u8::from_str_radix(hex, 16) {
85                Ok(byte) => {
86                    if byte == b'/' {
87                        return Err(Exception::throw_type(
88                            ctx,
89                            "File URL path must not include encoded / characters",
90                        ));
91                    }
92                    out.push(byte);
93                    i += 3;
94                    continue;
95                },
96                Err(_) => {
97                    return Err(Exception::throw_type(ctx, "Invalid percent-encoding in file URL"))
98                },
99            }
100        }
101        out.push(bytes[i]);
102        i += 1;
103    }
104    String::from_utf8(out).map_err(|_| Exception::throw_type(ctx, "File URL path is not valid UTF-8"))
105}
106
107pub fn url_format<'js>(url: Class<'js, URL<'js>>, options: Opt<Value<'js>>) -> Result<String> {
108    let url = url.borrow();
109    let mut string = url.protocol();
110    string.push_str("//");
111
112    let mut include_fragment = true;
113    let mut unicode_encode = false;
114    let mut include_auth = true;
115    let mut include_search = true;
116
117    // Parse options if provided
118    if let Some(options) = options.into_inner() {
119        if let Some(options) = options.as_object() {
120            if let Ok(value) = options.get("unicode") {
121                unicode_encode = value;
122            }
123            if let Ok(value) = options.get("auth") {
124                include_auth = value;
125            }
126            if let Ok(value) = options.get("fragment") {
127                include_fragment = value;
128            }
129            if let Ok(value) = options.get("search") {
130                include_search = value
131            }
132        }
133    }
134
135    if include_auth {
136        let username = url.username();
137        let password = url.password();
138        if !username.is_empty() {
139            string.push_str(&username);
140            if !password.is_empty() {
141                string.push(':');
142                string.push_str(&password);
143            }
144            string.push('@');
145        }
146    }
147
148    if unicode_encode {
149        string.push_str(&domain_to_unicode(&url.host()));
150    } else {
151        string.push_str(&url.host());
152    }
153
154    string.push_str(&url.pathname());
155
156    if include_search {
157        string.push_str(&url.search());
158    }
159
160    if include_fragment {
161        string.push_str(&url.hash());
162    }
163
164    Ok(string)
165}
166
167/// Encode trailing space as `%20` in opaque paths before a setter runs.
168///
169/// Used by [`URLSearchParams`] which mutates the shared [`Url`] directly.
170pub fn convert_trailing_space(url: &mut Url) {
171    if is_special_scheme(url.scheme()) {
172        return;
173    }
174
175    let path = url.path();
176    let has_remaining = url.fragment().is_some() || url.query().is_some();
177
178    #[allow(clippy::manual_strip)]
179    if path.ends_with(' ') && has_remaining {
180        let new_path = [&path[..path.len() - 1], "%20"].concat();
181        url.set_path(&new_path);
182    }
183}
184
185/// Per WHATWG URL spec §4.5.3 ("URL serializer"), the `/.` path sentinel is
186/// only inserted when a URL has no host AND its path starts with `//`. The
187/// `url` crate inserts the sentinel during parsing and can leave it in the
188/// serialization even after a host is set, breaking WPT `url-setters`
189/// subtests like `<non-spec:/.//p>.hostname = 'h'`.
190///
191/// This strips the sentinel whenever the URL has a non-empty host and the
192/// path begins with `/./`.
193/// Per WHATWG URL spec §4.2, a file URL path segment matching `[A-Za-z]|`
194/// followed by `/`, `\`, `?`, `#`, or end-of-path is a Windows drive letter.
195/// Parsers normalize the `|` to `:`. The `url` crate doesn't perform this
196/// rewrite itself, so we do it after parsing (WPT `url-constructor.any.js`
197/// "Parsing: <file:///w|/m>").
198/// When a `file://HOST/C:/...` string is parsed, the `url` crate drops
199/// HOST (normalizing to `file:///C:/...`). Per WHATWG URL spec the host
200/// must be preserved when non-empty (drive-letter state only applies when
201/// host is null). Extract the host from the original source string and
202/// re-set it on the parsed URL so downstream `join()` sees the host.
203pub fn preserve_file_url_host(source: &str, mut url: Url) -> Url {
204    if url.scheme() != "file" {
205        return url;
206    }
207    if url.host_str().is_some_and(|h| !h.is_empty()) {
208        return url;
209    }
210    // Look for `file://HOST/...` in the original string.
211    let Some(rest) = source.strip_prefix("file://") else {
212        return url;
213    };
214    let Some((host, _)) = rest.split_once('/') else {
215        return url;
216    };
217    if host.is_empty() {
218        return url;
219    }
220    let _ = url.set_host(Some(host));
221    url
222}
223
224/// When resolving a relative URL against a file:// base whose first path
225/// segment is a Windows drive letter (e.g. `file://h/C:/a/b`), the url crate
226/// loses the host during `join`. Per WHATWG URL spec the host must be
227/// preserved (WPT `url-constructor.any.js` "<file://h/C:/a/b>" base).
228/// Patch the joined URL by restoring the base's host.
229pub fn restore_file_url_host(base: &Url, joined: &mut Url) {
230    if base.scheme() != "file" || joined.scheme() != "file" {
231        return;
232    }
233    // Only when base had a host and joined has none / empty.
234    let Some(base_host) = base.host_str() else {
235        return;
236    };
237    if base_host.is_empty() {
238        return;
239    }
240    if joined.host_str().is_some_and(|h| !h.is_empty()) {
241        return;
242    }
243    // Only when base's first path segment is a Windows drive letter — that's
244    // the code path that the url crate mishandles.
245    let base_path = base.path();
246    let is_drive_letter_first_seg = base_path
247        .as_bytes()
248        .get(1)
249        .is_some_and(|b| b.is_ascii_alphabetic())
250        && base_path.as_bytes().get(2) == Some(&b':')
251        && matches!(base_path.as_bytes().get(3), Some(&b'/') | None);
252    if !is_drive_letter_first_seg {
253        return;
254    }
255    let _ = joined.set_host(Some(base_host));
256}
257
258pub fn normalize_windows_drive_letter(url: &mut Url) {
259    if url.scheme() != "file" {
260        return;
261    }
262    let path = url.path();
263    let bytes = path.as_bytes();
264    // Expect path like "/<letter>|/..." — 4+ bytes, leading slash, letter,
265    // pipe, trailing slash.
266    if bytes.len() < 4
267        || bytes[0] != b'/'
268        || !bytes[1].is_ascii_alphabetic()
269        || bytes[2] != b'|'
270        || bytes[3] != b'/'
271    {
272        return;
273    }
274    let new_path = ["/", &path[1..2], ":", &path[3..]].concat();
275    url.set_path(&new_path);
276}
277
278/// Per WHATWG URL spec, a non-special URL with an empty host can have its
279/// path erased (WPT `url-setters.any.js`). The `url` crate keeps a trailing
280/// `/` after the authority; reparse the serialization with it stripped when
281/// the caller has explicitly set an empty pathname on such a URL.
282pub fn erase_empty_host_path(url: &mut Url) {
283    if is_special_scheme(url.scheme()) {
284        return;
285    }
286    if url.path() != "/" {
287        return;
288    }
289    let serialized = url.as_str();
290    // Serialized form must be `<scheme>://` + `/` to qualify. (`scheme:/`,
291    // without authority, isn't eligible — the extra `/` is not a sentinel
292    // but a real path character.)
293    let Some(scheme_end) = serialized.find("://") else {
294        return;
295    };
296    let authority_and_path = &serialized[scheme_end + 3..];
297    // After "://": optional userinfo + host + port, then the path. If the
298    // path is just "/" and everything before is empty, the full
299    // authority_and_path is "/".
300    if authority_and_path != "/" {
301        return;
302    }
303    // Strip the trailing `/`.
304    let stripped = &serialized[..serialized.len() - 1];
305    if let Ok(reparsed) = Url::parse(stripped) {
306        *url = reparsed;
307    }
308}
309
310pub fn strip_path_sentinel(url: &mut Url) {
311    if is_special_scheme(url.scheme()) {
312        return;
313    }
314    // Path starting with `//` is what triggers the `/.` sentinel in the url
315    // crate's serialization — but the sentinel is only spec-correct when
316    // there's no authority. If the URL has `://` and its serialization still
317    // contains `/./` at the path boundary, reparse with it stripped.
318    if !url.path().starts_with("//") {
319        return;
320    }
321    let serialized = url.as_str();
322    // Authority is present iff serialization contains "://".
323    let Some(auth_start) = serialized.find("://") else {
324        return;
325    };
326    let after_auth = auth_start + 3;
327    // Look for next `/` that starts the path region.
328    let Some(path_start_rel) = serialized[after_auth..].find('/') else {
329        return;
330    };
331    let path_idx = after_auth + path_start_rel;
332    if serialized[path_idx..].starts_with("/./") {
333        let stripped = [&serialized[..path_idx], &serialized[path_idx + 2..]].concat();
334        if let Ok(reparsed) = Url::parse(&stripped) {
335            *url = reparsed;
336        }
337    }
338}
339
340pub fn init(ctx: &Ctx<'_>) -> Result<()> {
341    let globals = ctx.globals();
342
343    Class::<URLSearchParams>::define(&globals)?;
344    Class::<URL>::define(&globals)?;
345
346    Ok(())
347}
348
349pub struct UrlModule;
350
351impl ModuleDef for UrlModule {
352    fn declare(declare: &Declarations) -> Result<()> {
353        declare.declare(stringify!(URL))?;
354        declare.declare(stringify!(URLSearchParams))?;
355        declare.declare("urlToHttpOptions")?;
356        declare.declare("domainToUnicode")?;
357        declare.declare("domainToASCII")?;
358        declare.declare("fileURLToPath")?;
359        declare.declare("pathToFileURL")?;
360        declare.declare("format")?;
361        declare.declare("default")?;
362        Ok(())
363    }
364
365    fn evaluate<'js>(ctx: &Ctx<'js>, exports: &Exports<'js>) -> Result<()> {
366        let globals = ctx.globals();
367        BasePrimordials::init(ctx)?;
368        let url: Constructor = globals.get(stringify!(URL))?;
369        let url_search_params: Constructor = globals.get(stringify!(URLSearchParams))?;
370
371        export_default(ctx, exports, |default| {
372            default.set(stringify!(URL), url)?;
373            default.set(stringify!(URLSearchParams), url_search_params)?;
374            default.set("urlToHttpOptions", Func::from(url_to_http_options))?;
375            default.set(
376                "domainToUnicode",
377                Func::from(|domain: String| domain_to_unicode(&domain)),
378            )?;
379            default.set(
380                "domainToASCII",
381                Func::from(|domain: String| domain_to_ascii(&domain)),
382            )?;
383            default.set("fileURLToPath", Func::from(file_url_to_path))?;
384            default.set("pathToFileURL", Func::from(path_to_file_url))?;
385            default.set("format", Func::from(url_format))?;
386            Ok(())
387        })?;
388
389        Ok(())
390    }
391}
392
393impl From<UrlModule> for ModuleInfo<UrlModule> {
394    fn from(val: UrlModule) -> Self {
395        ModuleInfo {
396            name: "url",
397            module: val,
398        }
399    }
400}