deno_path_util/
lib.rs

1// Copyright 2018-2025 the Deno authors. MIT license.
2
3#![deny(clippy::print_stderr)]
4#![deny(clippy::print_stdout)]
5#![deny(clippy::unused_async)]
6#![deny(clippy::unnecessary_wraps)]
7
8use deno_error::JsError;
9use std::borrow::Cow;
10use std::path::Component;
11use std::path::Path;
12use std::path::PathBuf;
13use sys_traits::SystemRandom;
14use sys_traits::impls::is_windows;
15use thiserror::Error;
16use url::Url;
17
18pub mod fs;
19
20/// Gets the parent of this url.
21pub fn url_parent(url: &Url) -> Url {
22  let mut url = url.clone();
23  // don't use url.segments() because it will strip the leading slash
24  let mut segments = url.path().split('/').collect::<Vec<_>>();
25  if segments.iter().all(|s| s.is_empty()) {
26    return url;
27  }
28  if let Some(last) = segments.last() {
29    if last.is_empty() {
30      segments.pop();
31    }
32    segments.pop();
33    let new_path = format!("{}/", segments.join("/"));
34    url.set_path(&new_path);
35  }
36  url
37}
38
39#[derive(Debug, Error, deno_error::JsError)]
40#[class(uri)]
41#[error("Could not convert URL to file path.\n  URL: {0}")]
42pub struct UrlToFilePathError(pub Url);
43
44/// Attempts to convert a url to a file path. By default, uses the Url
45/// crate's `to_file_path()` method, but falls back to try and resolve unix-style
46/// paths on Windows.
47pub fn url_to_file_path(url: &Url) -> Result<PathBuf, UrlToFilePathError> {
48  let result = if url.scheme() != "file" {
49    Err(())
50  } else {
51    url_to_file_path_inner(url)
52  };
53  match result {
54    Ok(path) => Ok(path),
55    Err(()) => Err(UrlToFilePathError(url.clone())),
56  }
57}
58
59fn url_to_file_path_inner(url: &Url) -> Result<PathBuf, ()> {
60  #[cfg(any(unix, windows, target_os = "redox", target_os = "wasi"))]
61  return url_to_file_path_real(url);
62  #[cfg(not(any(unix, windows, target_os = "redox", target_os = "wasi")))]
63  url_to_file_path_wasm(url)
64}
65
66#[cfg(any(unix, windows, target_os = "redox", target_os = "wasi"))]
67fn url_to_file_path_real(url: &Url) -> Result<PathBuf, ()> {
68  if cfg!(windows) {
69    match url.to_file_path() {
70      Ok(path) => Ok(path),
71      Err(()) => {
72        // This might be a unix-style path which is used in the tests even on Windows.
73        // Attempt to see if we can convert it to a `PathBuf`. This code should be removed
74        // once/if https://github.com/servo/rust-url/issues/730 is implemented.
75        if url.scheme() == "file"
76          && url.host().is_none()
77          && url.port().is_none()
78          && url.path_segments().is_some()
79        {
80          let path_str = url.path();
81          match String::from_utf8(
82            percent_encoding::percent_decode(path_str.as_bytes()).collect(),
83          ) {
84            Ok(path_str) => Ok(PathBuf::from(path_str)),
85            Err(_) => Err(()),
86          }
87        } else {
88          Err(())
89        }
90      }
91    }
92  } else {
93    url.to_file_path()
94  }
95}
96
97#[cfg(any(
98  test,
99  not(any(unix, windows, target_os = "redox", target_os = "wasi"))
100))]
101#[allow(clippy::unnecessary_wraps)]
102fn url_to_file_path_wasm(url: &Url) -> Result<PathBuf, ()> {
103  fn is_windows_path_segment(url: &str) -> bool {
104    let mut chars = url.chars();
105
106    let first_char = chars.next();
107    if first_char.is_none() || !first_char.unwrap().is_ascii_alphabetic() {
108      return false;
109    }
110
111    if chars.next() != Some(':') {
112      return false;
113    }
114
115    chars.next().is_none()
116  }
117
118  let path_segments = url.path_segments().unwrap().collect::<Vec<_>>();
119  let mut final_text = String::new();
120  let mut is_windows_share = false;
121  if let Some(host) = url.host_str() {
122    final_text.push_str("\\\\");
123    final_text.push_str(host);
124    is_windows_share = true;
125  }
126  for segment in path_segments.iter() {
127    if is_windows_share {
128      final_text.push('\\');
129    } else if !final_text.is_empty() {
130      final_text.push('/');
131    }
132    final_text.push_str(
133      &percent_encoding::percent_decode_str(segment).decode_utf8_lossy(),
134    );
135  }
136  if !is_windows_share && !is_windows_path_segment(path_segments[0]) {
137    final_text = format!("/{}", final_text);
138  }
139  Ok(PathBuf::from(final_text))
140}
141
142/// Normalize all intermediate components of the path (ie. remove "./" and "../" components).
143/// Similar to `fs::canonicalize()` but doesn't resolve symlinks.
144///
145/// Adapted from Cargo
146/// <https://github.com/rust-lang/cargo/blob/af307a38c20a753ec60f0ad18be5abed3db3c9ac/src/cargo/util/paths.rs#L60-L85>
147#[inline]
148pub fn normalize_path(path: Cow<Path>) -> Cow<Path> {
149  fn should_normalize(path: &Path) -> bool {
150    if path_has_trailing_separator(path) {
151      return true;
152    }
153
154    let mut last_part = None;
155    for component in path.components() {
156      match component {
157        Component::CurDir | Component::ParentDir => {
158          return true;
159        }
160        Component::Prefix(..) | Component::RootDir => {
161          // ok
162        }
163        Component::Normal(component) => {
164          last_part = Some(component);
165        }
166      }
167    }
168
169    if is_windows()
170      && let Some(last_part) = last_part
171    {
172      let bytes = last_part.as_encoded_bytes();
173      if bytes.ends_with(b".") || bytes.ends_with(b" ") {
174        return true;
175      }
176    }
177
178    path_has_cur_dir_separator(path)
179  }
180
181  fn path_has_trailing_separator(path: &Path) -> bool {
182    #[cfg(unix)]
183    let raw = std::os::unix::ffi::OsStrExt::as_bytes(path.as_os_str());
184    #[cfg(windows)]
185    let raw = path.as_os_str().as_encoded_bytes();
186    #[cfg(target_arch = "wasm32")]
187    let raw = path.to_string_lossy();
188    #[cfg(target_arch = "wasm32")]
189    let raw = raw.as_bytes();
190
191    if sys_traits::impls::is_windows() {
192      raw.contains(&b'/') || raw.ends_with(b"\\")
193    } else {
194      raw.ends_with(b"/")
195    }
196  }
197
198  // Rust normalizes away `Component::CurDir` most of the time
199  // so we need to explicitly check for it in the bytes
200  fn path_has_cur_dir_separator(path: &Path) -> bool {
201    #[cfg(unix)]
202    let raw = std::os::unix::ffi::OsStrExt::as_bytes(path.as_os_str());
203    #[cfg(windows)]
204    let raw = path.as_os_str().as_encoded_bytes();
205    #[cfg(target_arch = "wasm32")]
206    let raw = path.to_string_lossy();
207    #[cfg(target_arch = "wasm32")]
208    let raw = raw.as_bytes();
209
210    if raw.ends_with(b"\\.") || raw.ends_with(b"/.") {
211      return true;
212    }
213
214    if sys_traits::impls::is_windows() {
215      for window in raw.windows(3) {
216        if matches!(window, [b'\\', b'.', b'\\']) {
217          return true;
218        }
219      }
220    } else {
221      for window in raw.windows(3) {
222        if matches!(window, [b'/', b'.', b'/']) {
223          return true;
224        }
225      }
226    }
227
228    false
229  }
230
231  fn inner(path: &Path) -> PathBuf {
232    let mut components = path.components().peekable();
233    let mut ret =
234      if let Some(c @ Component::Prefix(..)) = components.peek().cloned() {
235        components.next();
236        PathBuf::from(c.as_os_str())
237      } else {
238        PathBuf::new()
239      };
240
241    for component in components {
242      match component {
243        Component::Prefix(..) => unreachable!(),
244        Component::RootDir => {
245          ret.push(component.as_os_str());
246        }
247        Component::CurDir => {}
248        Component::ParentDir => {
249          ret.pop();
250        }
251        Component::Normal(c) => {
252          if is_windows() {
253            let bytes = c.as_encoded_bytes();
254            // Strip trailing dots and spaces on Windows
255            let mut end = bytes.len();
256            while end > 0 && (bytes[end - 1] == b'.' || bytes[end - 1] == b' ')
257            {
258              end -= 1;
259            }
260            if end == bytes.len() {
261              ret.push(c);
262            } else if end > 0 {
263              #[cfg(windows)]
264              {
265                use std::os::windows::ffi::{OsStrExt, OsStringExt};
266                let wide: Vec<u16> = c.encode_wide().collect();
267                let trimmed = std::ffi::OsString::from_wide(&wide[..end]);
268                ret.push(trimmed);
269              }
270              // SAFETY: trimmed spaces and dots only
271              #[cfg(not(windows))]
272              unsafe {
273                let trimmed =
274                  std::ffi::OsStr::from_encoded_bytes_unchecked(&bytes[..end]);
275                ret.push(trimmed);
276              }
277            }
278          } else {
279            ret.push(c);
280          }
281        }
282      }
283    }
284    ret
285  }
286
287  if should_normalize(&path) {
288    Cow::Owned(inner(&path))
289  } else {
290    path
291  }
292}
293
294#[derive(Debug, Clone, Error, deno_error::JsError, PartialEq, Eq)]
295#[class(uri)]
296#[error("Could not convert path to URL.\n  Path: {0}")]
297pub struct PathToUrlError(pub PathBuf);
298
299#[allow(clippy::result_unit_err)]
300pub fn url_from_file_path(path: &Path) -> Result<Url, PathToUrlError> {
301  let path = normalize_path(Cow::Borrowed(path));
302  #[cfg(any(unix, windows, target_os = "redox", target_os = "wasi"))]
303  return Url::from_file_path(&path)
304    .map_err(|()| PathToUrlError(path.to_path_buf()));
305  #[cfg(not(any(unix, windows, target_os = "redox", target_os = "wasi")))]
306  url_from_file_path_wasm(&path)
307    .map_err(|()| PathToUrlError(path.to_path_buf()))
308}
309
310#[allow(clippy::result_unit_err)]
311pub fn url_from_directory_path(path: &Path) -> Result<Url, PathToUrlError> {
312  let path = normalize_path(Cow::Borrowed(path));
313  #[cfg(any(unix, windows, target_os = "redox", target_os = "wasi"))]
314  return Url::from_directory_path(&path)
315    .map_err(|()| PathToUrlError(path.to_path_buf()));
316  #[cfg(not(any(unix, windows, target_os = "redox", target_os = "wasi")))]
317  url_from_directory_path_wasm(&path)
318    .map_err(|()| PathToUrlError(path.to_path_buf()))
319}
320
321#[cfg(any(
322  test,
323  not(any(unix, windows, target_os = "redox", target_os = "wasi"))
324))]
325fn url_from_directory_path_wasm(path: &Path) -> Result<Url, ()> {
326  let mut url = url_from_file_path_wasm(path)?;
327  url.path_segments_mut().unwrap().push("");
328  Ok(url)
329}
330
331#[cfg(any(
332  test,
333  not(any(unix, windows, target_os = "redox", target_os = "wasi"))
334))]
335fn url_from_file_path_wasm(path: &Path) -> Result<Url, ()> {
336  use std::path::Component;
337
338  let original_path = path.to_string_lossy();
339  let mut path_str = original_path;
340  // assume paths containing backslashes are windows paths
341  if path_str.contains('\\') {
342    let mut url = Url::parse("file://").unwrap();
343    if let Some(next) = path_str.strip_prefix(r#"\\?\UNC\"#) {
344      if let Some((host, rest)) = next.split_once('\\')
345        && url.set_host(Some(host)).is_ok()
346      {
347        path_str = rest.to_string().into();
348      }
349    } else if let Some(next) = path_str.strip_prefix(r#"\\?\"#) {
350      path_str = next.to_string().into();
351    } else if let Some(next) = path_str.strip_prefix(r#"\\"#)
352      && let Some((host, rest)) = next.split_once('\\')
353      && url.set_host(Some(host)).is_ok()
354    {
355      path_str = rest.to_string().into();
356    }
357
358    for component in path_str.split('\\') {
359      url.path_segments_mut().unwrap().push(component);
360    }
361
362    Ok(url)
363  } else {
364    let mut url = Url::parse("file://").unwrap();
365    for component in path.components() {
366      match component {
367        Component::RootDir => {
368          url.path_segments_mut().unwrap().push("");
369        }
370        Component::Normal(segment) => {
371          url
372            .path_segments_mut()
373            .unwrap()
374            .push(&segment.to_string_lossy());
375        }
376        Component::Prefix(_) | Component::CurDir | Component::ParentDir => {
377          return Err(());
378        }
379      }
380    }
381
382    Ok(url)
383  }
384}
385
386#[cfg(not(windows))]
387#[inline]
388pub fn strip_unc_prefix(path: PathBuf) -> PathBuf {
389  path
390}
391
392/// Strips the unc prefix (ex. \\?\) from Windows paths.
393#[cfg(windows)]
394pub fn strip_unc_prefix(path: PathBuf) -> PathBuf {
395  use std::path::Component;
396  use std::path::Prefix;
397
398  let mut components = path.components();
399  match components.next() {
400    Some(Component::Prefix(prefix)) => {
401      match prefix.kind() {
402        // \\?\device
403        Prefix::Verbatim(device) => {
404          let mut path = PathBuf::new();
405          path.push(format!(r"\\{}\", device.to_string_lossy()));
406          path.extend(components.filter(|c| !matches!(c, Component::RootDir)));
407          path
408        }
409        // \\?\c:\path
410        Prefix::VerbatimDisk(_) => {
411          let mut path = PathBuf::new();
412          path.push(prefix.as_os_str().to_string_lossy().replace(r"\\?\", ""));
413          path.extend(components);
414          path
415        }
416        // \\?\UNC\hostname\share_name\path
417        Prefix::VerbatimUNC(hostname, share_name) => {
418          let mut path = PathBuf::new();
419          path.push(format!(
420            r"\\{}\{}\",
421            hostname.to_string_lossy(),
422            share_name.to_string_lossy()
423          ));
424          path.extend(components.filter(|c| !matches!(c, Component::RootDir)));
425          path
426        }
427        _ => path,
428      }
429    }
430    _ => path,
431  }
432}
433
434/// Returns true if the input string starts with a sequence of characters
435/// that could be a valid URI scheme, like 'https:', 'git+ssh:' or 'data:'.
436///
437/// According to RFC 3986 (https://tools.ietf.org/html/rfc3986#section-3.1),
438/// a valid scheme has the following format:
439///   scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )
440///
441/// We additionally require the scheme to be at least 2 characters long,
442/// because otherwise a windows path like c:/foo would be treated as a URL,
443/// while no schemes with a one-letter name actually exist.
444pub fn specifier_has_uri_scheme(specifier: &str) -> bool {
445  let mut chars = specifier.chars();
446  let mut len = 0usize;
447  // The first character must be a letter.
448  match chars.next() {
449    Some(c) if c.is_ascii_alphabetic() => len += 1,
450    _ => return false,
451  }
452  // Second and following characters must be either a letter, number,
453  // plus sign, minus sign, or dot.
454  loop {
455    match chars.next() {
456      Some(c) if c.is_ascii_alphanumeric() || "+-.".contains(c) => len += 1,
457      Some(':') if len >= 2 => return true,
458      _ => return false,
459    }
460  }
461}
462
463#[derive(Debug, Clone, Error, JsError, PartialEq, Eq)]
464pub enum ResolveUrlOrPathError {
465  #[error(transparent)]
466  #[class(inherit)]
467  UrlParse(url::ParseError),
468  #[error(transparent)]
469  #[class(inherit)]
470  PathToUrl(PathToUrlError),
471}
472
473/// Takes a string representing either an absolute URL or a file path,
474/// as it may be passed to deno as a command line argument.
475/// The string is interpreted as a URL if it starts with a valid URI scheme,
476/// e.g. 'http:' or 'file:' or 'git+ssh:'. If not, it's interpreted as a
477/// file path; if it is a relative path it's resolved relative to passed
478/// `current_dir`.
479pub fn resolve_url_or_path(
480  specifier: &str,
481  current_dir: &Path,
482) -> Result<Url, ResolveUrlOrPathError> {
483  if specifier_has_uri_scheme(specifier) {
484    Url::parse(specifier).map_err(ResolveUrlOrPathError::UrlParse)
485  } else {
486    resolve_path(specifier, current_dir)
487      .map_err(ResolveUrlOrPathError::PathToUrl)
488  }
489}
490
491/// Converts a string representing a relative or absolute path into a
492/// ModuleSpecifier. A relative path is considered relative to the passed
493/// `current_dir`.
494pub fn resolve_path(
495  path_str: &str,
496  current_dir: &Path,
497) -> Result<Url, PathToUrlError> {
498  let path = current_dir.join(path_str);
499  url_from_file_path(&path)
500}
501
502#[derive(Debug, Error, Clone, PartialEq, Eq, deno_error::JsError)]
503pub enum SpecifierError {
504  // don't make this error a source because it's short
505  // and that causes unnecessary verbosity
506  #[class(inherit)]
507  #[error("invalid URL: {0}")]
508  InvalidUrl(url::ParseError),
509  #[class(type)]
510  #[error("Import \"{specifier}\" not a dependency")]
511  ImportPrefixMissing { specifier: String },
512}
513
514/// Given a specifier string and a referring module specifier, try to resolve
515/// the target module specifier, erroring if it cannot be resolved.
516///
517/// This function is useful for resolving specifiers in situations without an
518/// import map.
519pub fn resolve_import(
520  specifier: &str,
521  referrer: &Url,
522) -> Result<Url, SpecifierError> {
523  match Url::parse(specifier) {
524    // 1. Apply the URL parser to specifier.
525    //    If the result is not failure, return the result.
526    Ok(url) => Ok(url),
527
528    // 2. If specifier does not start with the character U+002F SOLIDUS (/),
529    //    the two-character sequence U+002E FULL STOP, U+002F SOLIDUS (./),
530    //    or the three-character sequence U+002E FULL STOP, U+002E FULL STOP,
531    //    U+002F SOLIDUS (../), return failure.
532    Err(url::ParseError::RelativeUrlWithoutBase)
533      if !(specifier.starts_with('/')
534        || specifier.starts_with("./")
535        || specifier.starts_with("../")) =>
536    {
537      Err(SpecifierError::ImportPrefixMissing {
538        specifier: specifier.to_string(),
539      })
540    }
541
542    // 3. Return the result of applying the URL parser to specifier with base
543    //    URL as the base URL.
544    Err(url::ParseError::RelativeUrlWithoutBase) => {
545      referrer.join(specifier).map_err(SpecifierError::InvalidUrl)
546    }
547
548    // If parsing the specifier as a URL failed for a different reason than
549    // it being relative, always return the original error. We don't want to
550    // return `ImportPrefixMissing` or `InvalidBaseUrl` if the real
551    // problem lies somewhere else.
552    Err(err) => Err(SpecifierError::InvalidUrl(err)),
553  }
554}
555
556pub fn get_atomic_path(sys: &impl SystemRandom, path: &Path) -> PathBuf {
557  let rand = gen_rand_path_component(sys);
558  let extension = format!("{rand}.tmp");
559  path.with_extension(extension)
560}
561
562fn gen_rand_path_component(sys: &impl SystemRandom) -> String {
563  use std::fmt::Write;
564  (0..4).fold(String::with_capacity(8), |mut output, _| {
565    write!(&mut output, "{:02x}", sys.sys_random_u8().unwrap()).unwrap();
566    output
567  })
568}
569
570pub fn is_relative_specifier(specifier: &str) -> bool {
571  let mut specifier_chars = specifier.chars();
572  let Some(first_char) = specifier_chars.next() else {
573    return false;
574  };
575  if first_char != '.' {
576    return false;
577  }
578  let Some(second_char) = specifier_chars.next() else {
579    return true;
580  };
581  if second_char == '/' {
582    return true;
583  }
584  let Some(third_char) = specifier_chars.next() else {
585    return second_char == '.';
586  };
587  second_char == '.' && third_char == '/'
588}
589
590#[cfg(test)]
591mod tests {
592  use super::*;
593
594  #[test]
595  fn test_url_parent() {
596    run_test("file:///", "file:///");
597    run_test("file:///test", "file:///");
598    run_test("file:///test/", "file:///");
599    run_test("file:///test/other", "file:///test/");
600    run_test("file:///test/other.txt", "file:///test/");
601    run_test("file:///test/other/", "file:///test/");
602
603    fn run_test(url: &str, expected: &str) {
604      let result = url_parent(&Url::parse(url).unwrap());
605      assert_eq!(result.to_string(), expected);
606    }
607  }
608
609  #[test]
610  fn test_url_to_file_path() {
611    run_success_test("file:///", "/");
612    run_success_test("file:///test", "/test");
613    run_success_test("file:///dir/test/test.txt", "/dir/test/test.txt");
614    #[cfg(not(debug_assertions))]
615    run_success_test(
616      "file:///dir/test%20test/test.txt",
617      "/dir/test test/test.txt",
618    );
619
620    assert_no_panic_url_to_file_path("file:/");
621    assert_no_panic_url_to_file_path("file://");
622    #[cfg(not(debug_assertions))]
623    assert_no_panic_url_to_file_path("file://asdf/");
624    assert_no_panic_url_to_file_path("file://asdf/66666/a.ts");
625
626    #[track_caller]
627    fn run_success_test(url: &str, expected_path: &str) {
628      let result = url_to_file_path(&Url::parse(url).unwrap()).unwrap();
629      assert_eq!(result, PathBuf::from(expected_path));
630    }
631
632    #[track_caller]
633    fn assert_no_panic_url_to_file_path(url: &str) {
634      let _result = url_to_file_path(&Url::parse(url).unwrap());
635    }
636  }
637
638  #[test]
639  fn test_url_to_file_path_wasm() {
640    #[track_caller]
641    fn convert(path: &str) -> String {
642      url_to_file_path_wasm(&Url::parse(path).unwrap())
643        .unwrap()
644        .to_string_lossy()
645        .into_owned()
646    }
647
648    assert_eq!(convert("file:///a/b/c.json"), "/a/b/c.json");
649    assert_eq!(convert("file:///D:/test/other.json"), "D:/test/other.json");
650    assert_eq!(
651      convert("file:///path%20with%20spaces/and%23special%25chars!.json"),
652      "/path with spaces/and#special%chars!.json",
653    );
654    assert_eq!(
655      convert("file:///C:/My%20Documents/file.txt"),
656      "C:/My Documents/file.txt"
657    );
658    assert_eq!(
659      convert("file:///a/b/%D0%BF%D1%80%D0%B8%D0%BC%D0%B5%D1%80.txt"),
660      "/a/b/пример.txt"
661    );
662    assert_eq!(
663      convert("file://server/share/folder/file.txt"),
664      "\\\\server\\share\\folder\\file.txt"
665    );
666  }
667
668  #[test]
669  fn test_url_from_file_path_wasm() {
670    #[track_caller]
671    fn convert(path: &str) -> String {
672      url_from_file_path_wasm(Path::new(path))
673        .unwrap()
674        .to_string()
675    }
676
677    assert_eq!(convert("/a/b/c.json"), "file:///a/b/c.json");
678    assert_eq!(
679      convert("D:\\test\\other.json"),
680      "file:///D:/test/other.json"
681    );
682    assert_eq!(
683      convert("/path with spaces/and#special%chars!.json"),
684      "file:///path%20with%20spaces/and%23special%25chars!.json"
685    );
686    assert_eq!(
687      convert("C:\\My Documents\\file.txt"),
688      "file:///C:/My%20Documents/file.txt"
689    );
690    assert_eq!(
691      convert("/a/b/пример.txt"),
692      "file:///a/b/%D0%BF%D1%80%D0%B8%D0%BC%D0%B5%D1%80.txt"
693    );
694    assert_eq!(
695      convert("\\\\server\\share\\folder\\file.txt"),
696      "file://server/share/folder/file.txt"
697    );
698    assert_eq!(convert(r#"\\?\UNC\server\share"#), "file://server/share");
699    assert_eq!(
700      convert(r"\\?\cat_pics\subfolder\file.jpg"),
701      "file:///cat_pics/subfolder/file.jpg"
702    );
703    assert_eq!(convert(r"\\?\cat_pics"), "file:///cat_pics");
704  }
705
706  #[test]
707  fn test_url_from_directory_path_wasm() {
708    #[track_caller]
709    fn convert(path: &str) -> String {
710      url_from_directory_path_wasm(Path::new(path))
711        .unwrap()
712        .to_string()
713    }
714
715    assert_eq!(convert("/a/b/c"), "file:///a/b/c/");
716    assert_eq!(convert("D:\\test\\other"), "file:///D:/test/other/");
717  }
718
719  #[cfg(windows)]
720  #[test]
721  fn test_strip_unc_prefix() {
722    use std::path::PathBuf;
723
724    run_test(r"C:\", r"C:\");
725    run_test(r"C:\test\file.txt", r"C:\test\file.txt");
726
727    run_test(r"\\?\C:\", r"C:\");
728    run_test(r"\\?\C:\test\file.txt", r"C:\test\file.txt");
729
730    run_test(r"\\.\C:\", r"\\.\C:\");
731    run_test(r"\\.\C:\Test\file.txt", r"\\.\C:\Test\file.txt");
732
733    run_test(r"\\?\UNC\localhost\", r"\\localhost");
734    run_test(r"\\?\UNC\localhost\c$\", r"\\localhost\c$");
735    run_test(
736      r"\\?\UNC\localhost\c$\Windows\file.txt",
737      r"\\localhost\c$\Windows\file.txt",
738    );
739    run_test(r"\\?\UNC\wsl$\deno.json", r"\\wsl$\deno.json");
740
741    run_test(r"\\?\server1", r"\\server1");
742    run_test(r"\\?\server1\e$\", r"\\server1\e$\");
743    run_test(
744      r"\\?\server1\e$\test\file.txt",
745      r"\\server1\e$\test\file.txt",
746    );
747
748    fn run_test(input: &str, expected: &str) {
749      assert_eq!(
750        super::strip_unc_prefix(PathBuf::from(input)),
751        PathBuf::from(expected)
752      );
753    }
754  }
755
756  #[test]
757  fn test_normalize_path_basic() {
758    let run_test = run_normalize_path_test;
759    run_test("a/../b", "b");
760    run_test("a/./b/", &PathBuf::from("a").join("b").to_string_lossy());
761    run_test(
762      "a/./b/../c",
763      &PathBuf::from("a").join("c").to_string_lossy(),
764    );
765  }
766
767  #[cfg(windows)]
768  #[test]
769  fn test_normalize_path_win() {
770    let run_test = run_normalize_path_test;
771
772    run_test("C:\\test\\file.txt", "C:\\test\\file.txt");
773    run_test("C:\\test\\./file.txt", "C:\\test\\file.txt");
774    run_test("C:\\test\\../other/file.txt", "C:\\other\\file.txt");
775    run_test("C:\\test\\../other\\file.txt", "C:\\other\\file.txt");
776    run_test(
777      "C:\\test\\removes_trailing_slash\\",
778      "C:\\test\\removes_trailing_slash",
779    );
780    run_test("C:\\a\\.\\b\\..\\c", "C:\\a\\c");
781    run_test("C:\\test\\.", "C:\\test");
782    run_test("C:\\test\\test...", "C:\\test\\test");
783    run_test("C:\\test\\test    ", "C:\\test\\test");
784  }
785
786  #[track_caller]
787  fn run_normalize_path_test(input: &str, expected: &str) {
788    assert_eq!(
789      normalize_path(Cow::Owned(PathBuf::from(input))).to_string_lossy(),
790      expected
791    );
792  }
793
794  #[test]
795  fn test_atomic_path() {
796    let sys = sys_traits::impls::InMemorySys::default();
797    sys.set_seed(Some(10));
798    let path = Path::new("/a/b/c.txt");
799    let atomic_path = get_atomic_path(&sys, path);
800    assert_eq!(atomic_path.parent().unwrap(), path.parent().unwrap());
801    assert_eq!(atomic_path.file_name().unwrap(), "c.3d3d3d3d.tmp");
802  }
803
804  #[test]
805  fn test_specifier_has_uri_scheme() {
806    let tests = vec![
807      ("http://foo.bar/etc", true),
808      ("HTTP://foo.bar/etc", true),
809      ("http:ftp:", true),
810      ("http:", true),
811      ("hTtP:", true),
812      ("ftp:", true),
813      ("mailto:spam@please.me", true),
814      ("git+ssh://git@github.com/denoland/deno", true),
815      ("blob:https://whatwg.org/mumbojumbo", true),
816      ("abc.123+DEF-ghi:", true),
817      ("abc.123+def-ghi:@", true),
818      ("", false),
819      (":not", false),
820      ("http", false),
821      ("c:dir", false),
822      ("X:", false),
823      ("./http://not", false),
824      ("1abc://kinda/but/no", false),
825      ("schluẞ://no/more", false),
826    ];
827
828    for (specifier, expected) in tests {
829      let result = specifier_has_uri_scheme(specifier);
830      assert_eq!(result, expected);
831    }
832  }
833
834  #[test]
835  fn test_resolve_url_or_path() {
836    // Absolute URL.
837    let mut tests: Vec<(&str, String)> = vec![
838      (
839        "http://deno.land/core/tests/006_url_imports.ts",
840        "http://deno.land/core/tests/006_url_imports.ts".to_string(),
841      ),
842      (
843        "https://deno.land/core/tests/006_url_imports.ts",
844        "https://deno.land/core/tests/006_url_imports.ts".to_string(),
845      ),
846    ];
847
848    // The local path tests assume that the cwd is the deno repo root. Note
849    // that we can't use `cwd` in miri tests, so we just use `/miri` instead.
850    let cwd = if cfg!(miri) {
851      PathBuf::from("/miri")
852    } else {
853      std::env::current_dir().unwrap()
854    };
855    let cwd_str = cwd.to_str().unwrap();
856
857    if cfg!(target_os = "windows") {
858      // Absolute local path.
859      let expected_url = "file:///C:/deno/tests/006_url_imports.ts";
860      tests.extend(vec![
861        (
862          r"C:/deno/tests/006_url_imports.ts",
863          expected_url.to_string(),
864        ),
865        (
866          r"C:\deno\tests\006_url_imports.ts",
867          expected_url.to_string(),
868        ),
869        (
870          r"\\?\C:\deno\tests\006_url_imports.ts",
871          expected_url.to_string(),
872        ),
873        // Not supported: `Url::from_file_path()` fails.
874        // (r"\\.\C:\deno\tests\006_url_imports.ts", expected_url.to_string()),
875        // Not supported: `Url::from_file_path()` performs the wrong conversion.
876        // (r"//./C:/deno/tests/006_url_imports.ts", expected_url.to_string()),
877      ]);
878
879      // Rooted local path without drive letter.
880      let expected_url = format!(
881        "file:///{}:/deno/tests/006_url_imports.ts",
882        cwd_str.get(..1).unwrap(),
883      );
884      tests.extend(vec![
885        (r"/deno/tests/006_url_imports.ts", expected_url.to_string()),
886        (r"\deno\tests\006_url_imports.ts", expected_url.to_string()),
887        (
888          r"\deno\..\deno\tests\006_url_imports.ts",
889          expected_url.to_string(),
890        ),
891        (r"\deno\.\tests\006_url_imports.ts", expected_url),
892      ]);
893
894      // Relative local path.
895      let expected_url = format!(
896        "file:///{}/tests/006_url_imports.ts",
897        cwd_str.replace('\\', "/")
898      );
899      tests.extend(vec![
900        (r"tests/006_url_imports.ts", expected_url.to_string()),
901        (r"tests\006_url_imports.ts", expected_url.to_string()),
902        (r"./tests/006_url_imports.ts", (*expected_url).to_string()),
903        (r".\tests\006_url_imports.ts", (*expected_url).to_string()),
904      ]);
905
906      // UNC network path.
907      let expected_url = "file://server/share/deno/cool";
908      tests.extend(vec![
909        (r"\\server\share\deno\cool", expected_url.to_string()),
910        (r"\\server/share/deno/cool", expected_url.to_string()),
911        // Not supported: `Url::from_file_path()` performs the wrong conversion.
912        // (r"//server/share/deno/cool", expected_url.to_string()),
913      ]);
914    } else {
915      // Absolute local path.
916      let expected_url = "file:///deno/tests/006_url_imports.ts";
917      tests.extend(vec![
918        ("/deno/tests/006_url_imports.ts", expected_url.to_string()),
919        ("//deno/tests/006_url_imports.ts", expected_url.to_string()),
920      ]);
921
922      // Relative local path.
923      let expected_url = format!("file://{cwd_str}/tests/006_url_imports.ts");
924      tests.extend(vec![
925        ("tests/006_url_imports.ts", expected_url.to_string()),
926        ("./tests/006_url_imports.ts", expected_url.to_string()),
927        (
928          "tests/../tests/006_url_imports.ts",
929          expected_url.to_string(),
930        ),
931        ("tests/./006_url_imports.ts", expected_url),
932      ]);
933    }
934
935    for (specifier, expected_url) in tests {
936      let url = resolve_url_or_path(specifier, &cwd).unwrap().to_string();
937      assert_eq!(url, expected_url);
938    }
939  }
940
941  #[test]
942  fn test_resolve_url_or_path_deprecated_error() {
943    use ResolveUrlOrPathError::*;
944    use url::ParseError::*;
945
946    let mut tests = vec![
947      ("https://eggplant:b/c", UrlParse(InvalidPort)),
948      ("https://:8080/a/b/c", UrlParse(EmptyHost)),
949    ];
950    if cfg!(target_os = "windows") {
951      let p = r"\\.\c:/stuff/deno/script.ts";
952      tests.push((p, PathToUrl(PathToUrlError(PathBuf::from(p)))));
953    }
954
955    for (specifier, expected_err) in tests {
956      let err =
957        resolve_url_or_path(specifier, &PathBuf::from("/")).unwrap_err();
958      assert_eq!(err, expected_err);
959    }
960  }
961
962  #[test]
963  fn test_is_relative_specifier() {
964    assert!(is_relative_specifier("."));
965    assert!(is_relative_specifier(".."));
966    assert!(is_relative_specifier("../"));
967    assert!(is_relative_specifier("../test"));
968    assert!(is_relative_specifier("./"));
969    assert!(is_relative_specifier("./test"));
970    assert!(!is_relative_specifier(""));
971    assert!(!is_relative_specifier("a"));
972    assert!(!is_relative_specifier(".test"));
973    assert!(!is_relative_specifier("..test"));
974    assert!(!is_relative_specifier("/test"));
975    assert!(!is_relative_specifier("test"));
976  }
977
978  #[test]
979  fn test_resolve_import() {
980    fn run_test(specifier: &str, base: &str, expected: &str) {
981      let actual =
982        resolve_import(specifier, &Url::parse(base).unwrap()).unwrap();
983      assert_eq!(actual.as_str(), expected);
984    }
985
986    run_test(
987      "./test.js",
988      "https://example.com",
989      "https://example.com/test.js",
990    );
991    run_test(
992      "https://deno.land/x/mod.ts",
993      "https://example.com",
994      "https://deno.land/x/mod.ts",
995    );
996    run_test(
997      "../test.js",
998      "https://example.com/sub",
999      "https://example.com/test.js",
1000    );
1001    run_test(
1002      "/test.js",
1003      "https://example.com/sub/dir/test",
1004      "https://example.com/test.js",
1005    );
1006
1007    match resolve_import("test.js", &Url::parse("https://example.com").unwrap())
1008    {
1009      Err(SpecifierError::ImportPrefixMissing { specifier }) => {
1010        assert_eq!(specifier, "test.js");
1011      }
1012      _ => unreachable!(),
1013    }
1014  }
1015}