1#![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
25pub 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
38pub 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
46pub 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 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
74fn 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 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
167pub 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
185pub 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 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
224pub fn restore_file_url_host(base: &Url, joined: &mut Url) {
230 if base.scheme() != "file" || joined.scheme() != "file" {
231 return;
232 }
233 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 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 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
278pub 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 let Some(scheme_end) = serialized.find("://") else {
294 return;
295 };
296 let authority_and_path = &serialized[scheme_end + 3..];
297 if authority_and_path != "/" {
301 return;
302 }
303 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 if !url.path().starts_with("//") {
319 return;
320 }
321 let serialized = url.as_str();
322 let Some(auth_start) = serialized.find("://") else {
324 return;
325 };
326 let after_auth = auth_start + 3;
327 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}