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