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>> {
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
54pub 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 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#[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
106fn 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 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
199pub 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
217pub 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 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
256pub fn restore_file_url_host(base: &Url, joined: &mut Url) {
262 if base.scheme() != "file" || joined.scheme() != "file" {
263 return;
264 }
265 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 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 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
310pub 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 let Some(scheme_end) = serialized.find("://") else {
326 return;
327 };
328 let authority_and_path = &serialized[scheme_end + 3..];
329 if authority_and_path != "/" {
333 return;
334 }
335 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 if !url.path().starts_with("//") {
351 return;
352 }
353 let serialized = url.as_str();
354 let Some(auth_start) = serialized.find("://") else {
356 return;
357 };
358 let after_auth = auth_start + 3;
359 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}