1use crate::files::IoError;
16use std::{
17 fs,
18 path::{Component, Path, PathBuf},
19};
20
21#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct FileModule {
25 pub id: String,
26 pub path: String,
27}
28
29pub fn percent_decode(text: &str) -> Option<String> {
32 let bytes = text.as_bytes();
33 let mut decoded = Vec::with_capacity(bytes.len());
34 let mut i = 0;
35 while i < bytes.len() {
36 if bytes[i] == b'%' {
37 let pair = text.get(i + 1..i + 3)?;
38 decoded.push(u8::from_str_radix(pair, 16).ok()?);
39 i += 3;
40 } else {
41 decoded.push(bytes[i]);
42 i += 1;
43 }
44 }
45 String::from_utf8(decoded).ok()
46}
47
48pub fn percent_encode(path: &str) -> String {
50 let mut encoded = String::with_capacity(path.len());
51 for &b in path.as_bytes() {
52 match b {
53 0..=0x20
54 | 0x7f..
55 | b'"'
56 | b'#'
57 | b'%'
58 | b'<'
59 | b'>'
60 | b'?'
61 | b'['
62 | b'\\'
63 | b']'
64 | b'^'
65 | b'`'
66 | b'{'
67 | b'|'
68 | b'}'
69 | b'~' => encoded.push_str(&format!("%{b:02X}")),
70 _ => encoded.push(char::from(b)),
71 }
72 }
73 encoded
74}
75
76enum Kind {
79 Skip,
80 Up,
81 Keep,
82}
83
84fn kind(segment: &str) -> Kind {
85 match segment.to_ascii_lowercase().as_str() {
86 "." | "%2e" => Kind::Skip,
87 ".." | ".%2e" | "%2e." | "%2e%2e" => Kind::Up,
88 _ => Kind::Keep,
89 }
90}
91
92fn segment(raw: &str) -> Option<String> {
95 let decoded = percent_decode(raw)?;
96 (!decoded.contains(['/', '\\', '\0', ':'])).then_some(decoded)
97}
98
99pub fn decode_specifier(specifier: &str) -> Option<(bool, String)> {
106 if specifier.contains([':', '\\', '?', '#']) {
107 return None;
108 }
109 let rooted = specifier.starts_with('/');
110 let mut kept: Vec<&str> = Vec::new();
111 for raw in specifier.split('/').skip(usize::from(rooted)) {
112 match kind(raw) {
113 Kind::Skip => {}
114 Kind::Up => match kept.last() {
115 None if rooted => {}
116 Some(&last) if last != ".." => {
117 kept.pop();
118 }
119 _ => kept.push(".."),
120 },
121 Kind::Keep => kept.push(raw),
122 }
123 }
124 let names: Option<Vec<String>> = kept.into_iter().map(segment).collect();
125 names.map(|names| (rooted, names.join("/")))
126}
127
128pub fn file_url_to_path(url: &str) -> Result<String, IoError> {
131 let Some(rest) = url.strip_prefix("file://") else {
132 return Err(IoError::message("only file modules are supported"));
133 };
134 if !rest.starts_with('/') {
135 return Err(IoError::refusal(
136 "ERR_INVALID_FILE_URL_HOST",
137 "File URL host must be \"localhost\" or empty",
138 ));
139 }
140 let invalid = || {
141 IoError::refusal(
142 "ERR_INVALID_FILE_URL_PATH",
143 if cfg!(windows) {
144 "File URL path must not include encoded \\ or / characters"
145 } else {
146 "File URL path must not include encoded / characters"
147 },
148 )
149 };
150 let lower = rest.to_ascii_lowercase();
153 if lower.contains("%2f") || (cfg!(windows) && lower.contains("%5c")) {
154 return Err(invalid());
155 }
156 let path = percent_decode(rest).ok_or_else(invalid)?;
157 let drive = path.as_bytes();
160 Ok(
161 if cfg!(windows) && drive.len() >= 3 && drive[2] == b':' && drive[1].is_ascii_alphabetic() {
162 path[1..].to_string()
163 } else {
164 path
165 },
166 )
167}
168
169pub fn path_to_file_url(path: &str) -> String {
171 let path = if cfg!(windows) {
172 path.replace('\\', "/")
173 } else {
174 path.to_string()
175 };
176 let rooted = if path.starts_with('/') {
177 path
178 } else {
179 format!("/{path}")
180 };
181 format!("file://{}", percent_encode(&rooted))
182}
183
184fn windows_path(path: &str) -> std::io::Result<&str> {
190 if let Some(plain) = path.strip_prefix(r"\\?\") {
191 let bytes = plain.as_bytes();
192 if bytes.len() >= 3
193 && bytes[0].is_ascii_alphabetic()
194 && bytes[1] == b':'
195 && bytes[2] == b'\\'
196 {
197 return Ok(plain);
198 }
199 } else if !path.starts_with(r"\\") && !path.starts_with("//") {
200 return Ok(path);
201 }
202 Err(std::io::Error::new(
203 std::io::ErrorKind::Unsupported,
204 "UNC and device module paths are not supported",
205 ))
206}
207
208fn path_text(path: &Path) -> std::io::Result<String> {
211 path.to_str().map(str::to_string).ok_or_else(|| {
212 std::io::Error::new(
213 std::io::ErrorKind::InvalidData,
214 "non-UTF-8 module paths are not supported",
215 )
216 })
217}
218
219fn absolute_path(path: &str) -> std::io::Result<String> {
224 let path = if cfg!(windows) {
225 windows_path(path)?
226 } else {
227 path
228 };
229 let absolute = std::path::absolute(if path.is_empty() { "." } else { path })?;
230 let mut normalized = PathBuf::new();
231 for component in absolute.components() {
232 match component {
233 Component::ParentDir => {
234 normalized.pop();
235 }
236 Component::CurDir => {}
237 other => normalized.push(other.as_os_str()),
238 }
239 }
240 let mut result = path_text(&normalized)?;
241 if path.ends_with(std::path::is_separator) && !result.ends_with(std::path::is_separator) {
242 result.push(std::path::MAIN_SEPARATOR);
243 }
244 if cfg!(windows) {
247 windows_path(&result).map(str::to_string)
248 } else {
249 Ok(result)
250 }
251}
252
253fn directory_specifier(name: &str) -> bool {
256 name.ends_with('/') || !matches!(kind(name.rsplit('/').next().unwrap_or("")), Kind::Keep)
257}
258
259fn has_legacy_drive_marker(name: &str) -> bool {
264 name.split('/')
265 .any(|part| matches!(part.as_bytes(), [letter, b'|'] if letter.is_ascii_alphabetic()))
266}
267
268fn needs_url_preprocessing(name: &str) -> bool {
272 name.trim_matches(|c: char| c <= ' ') != name || name.contains(['\t', '\n', '\r'])
273}
274
275fn entry_path_needs_url_preprocessing(path: &str) -> bool {
279 path.ends_with(|c: char| c < ' ' && !matches!(c, '\0' | '\t' | '\n' | '\r'))
280}
281
282fn real_path(path: &str) -> std::io::Result<String> {
285 if path.ends_with(std::path::is_separator) && !fs::metadata(path)?.is_dir() {
288 return Err(std::io::ErrorKind::NotADirectory.into());
289 }
290 let real = path_text(&fs::canonicalize(path)?)?;
291 if cfg!(windows) {
292 windows_path(&real).map(str::to_string)
293 } else {
294 Ok(real)
295 }
296}
297
298fn directory(path: &str) -> &str {
300 path.rfind('/').map_or("", |i| &path[..i])
301}
302
303pub fn resolve_file_module(name: &str, parent: Option<&str>) -> Result<FileModule, IoError> {
306 let loading = match parent {
307 None => name.to_string(),
308 Some(parent) => {
309 let invalid = || IoError::message("invalid module specifier");
310 if needs_url_preprocessing(name)
317 || name.starts_with("//")
318 || has_legacy_drive_marker(name)
319 {
320 return Err(invalid());
321 }
322 let (rooted, names) = decode_specifier(name).ok_or_else(invalid)?;
323 let importer = file_url_to_path(parent)?;
324 let mut path = if rooted {
325 path_text(&Path::new(&importer).join(format!("/{names}")))
328 .map_err(|e| crate::files::failure(&e, "resolveFileModule", name))?
329 } else if name.is_empty() {
330 importer
331 } else {
332 format!("{}/{names}", directory(&importer))
333 };
334 if directory_specifier(name) && !path.ends_with('/') {
335 path.push('/');
336 }
337 path
338 }
339 };
340 let loading = absolute_path(&loading)
341 .map_err(|e| crate::files::failure(&e, "resolveFileModule", &loading))?;
342 if parent.is_none() && entry_path_needs_url_preprocessing(&loading) {
343 return Err(IoError::message(
344 "entry paths with terminal C0 controls are not supported",
345 ));
346 }
347 let path = real_path(&loading).map_err(|e| crate::files::failure(&e, "realpath", &loading))?;
348 Ok(FileModule {
349 id: path_to_file_url(&path),
350 path,
351 })
352}
353
354#[cfg(test)]
355mod test {
356 use super::*;
357
358 #[test]
361 fn specifiers() {
362 let rel = |s: &str| Some((false, s.to_string()));
363 let root = |s: &str| Some((true, s.to_string()));
364 for (specifier, expected) in [
365 ("./a", rel("a")),
366 ("../a", rel("../a")),
367 ("a", rel("a")),
368 ("a/b", rel("a/b")),
369 ("/a", root("a")),
370 ("/", root("")),
371 ("", rel("")),
372 (".", rel("")),
373 ("..", rel("..")),
374 ("./a/./b", rel("a/b")),
375 ("a/../b", rel("b")),
376 ("a/../../b", rel("../b")),
377 ("/a/../../b", root("b")),
378 ("a%20b", rel("a b")),
379 ("%2e%2e/a", rel("../a")),
380 ("./%2E/a", rel("a")),
381 (".%2e/x", rel("../x")),
382 ("a//b", rel("a//b")),
383 ("a/", rel("a/")),
384 ("a%2Fb", None),
385 ("a%5Cb", None),
386 ("a%3Ab", None),
387 ("a%00b", None),
388 ("a:b", None),
389 ("a\\b", None),
390 ("a?b", None),
391 ("a#b", None),
392 ("a%3Fb", rel("a?b")),
393 ("a%23b", rel("a#b")),
394 ("%zz", None),
395 ("%e0%a4", None),
396 ("é", rel("é")),
397 ("a%C3%A9", rel("aé")),
398 ("%2e", rel("")),
399 ("bad%/../dep", rel("dep")),
400 ("../../x", rel("../../x")),
401 ("./../x/../..", rel("../..")),
402 (".//x", rel("/x")),
403 ] {
404 assert_eq!(decode_specifier(specifier), expected, "{specifier:?}");
405 }
406 }
407
408 #[test]
409 fn directory_specifiers_keep_their_meaning() {
410 for name in [
411 "a/", "a//", "a/.", "a/%2e", "a/b/..", "a/b/.%2E", ".", "..", "/",
412 ] {
413 assert!(directory_specifier(name), "{name:?}");
414 }
415 for name in ["", "a", "./a", "...", "a%252e", "a%2e", "a%2Fb"] {
416 assert!(!directory_specifier(name), "{name:?}");
417 }
418 }
419
420 #[cfg(windows)]
423 #[test]
424 fn windows_root_relative_entries_use_the_current_drive() {
425 let module = resolve_file_module("Cargo.toml", None).unwrap();
426 let rooted = &module.path[2..];
427 for name in [rooted.to_string(), rooted.replace('\\', "/")] {
428 assert_eq!(resolve_file_module(&name, None).unwrap(), module);
429 }
430 let drive_relative = format!("{}Cargo.toml", &module.path[..2]);
431 assert_eq!(resolve_file_module(&drive_relative, None).unwrap(), module);
432 }
433
434 #[test]
435 fn percent_coding() {
436 assert_eq!(percent_decode("a%20b%C3%A9"), Some("a bé".to_string()));
437 assert_eq!(percent_decode("%2"), None);
438 assert_eq!(percent_decode("%g0"), None);
439 assert_eq!(percent_decode("%ff"), None);
440 assert_eq!(
442 percent_encode("/a b\\\"#%<>?[]^`{|}~\u{7f}\u{1}é😀x"),
443 "/a%20b%5C%22%23%25%3C%3E%3F%5B%5D%5E%60%7B%7C%7D%7E%7F%01%C3%A9%F0%9F%98%80x"
444 );
445 assert_eq!(
446 percent_encode("/a-b_c.d/e!f$g&h'i(j)k*l+m,n;o=p@q"),
447 "/a-b_c.d/e!f$g&h'i(j)k*l+m,n;o=p@q"
448 );
449 }
450
451 #[test]
452 fn windows_drive_paths_keep_their_native_spelling() {
453 for (path, expected) in [
454 (r"C:\work\a b.f.js", r"C:\work\a b.f.js"),
455 (r"\\?\C:\work\a b.f.js", r"C:\work\a b.f.js"),
456 (r"\\?\d:\src\m.f.js", r"d:\src\m.f.js"),
457 ] {
458 assert_eq!(windows_path(path).unwrap(), expected);
459 }
460 }
461
462 #[test]
463 fn unsupported_windows_paths_do_not_become_module_ids() {
464 for path in [
465 r"\\server\share\src\m.f.js",
466 "//server/share/src/m.f.js",
467 r"\\?\UNC\server\share\src\m.f.js",
468 r"\\?\Volume{example}\src\m.f.js",
469 r"\\.\pipe\module",
470 ] {
471 let error = windows_path(path).unwrap_err();
472 assert_eq!(error.kind(), std::io::ErrorKind::Unsupported);
473 assert_eq!(
474 error.to_string(),
475 "UNC and device module paths are not supported"
476 );
477 }
478 }
479
480 #[cfg(windows)]
482 #[test]
483 fn unc_module_inputs_are_refused_before_filesystem_io() {
484 for path in [
485 r"\\server\share\m.f.js",
486 "//server/share/m.f.js",
487 r"\\?\UNC\server\share\m.f.js",
488 ] {
489 let error = resolve_file_module(path, None).unwrap_err();
490 assert_eq!(error.code, None);
491 assert!(
492 error
493 .message
494 .contains("UNC and device module paths are not supported")
495 );
496 }
497 }
498
499 #[test]
500 fn file_urls() {
501 assert_eq!(path_to_file_url("/a/b c"), "file:///a/b%20c");
502 if cfg!(windows) {
503 assert_eq!(path_to_file_url(r"C:\a\b"), "file:///C:/a/b");
504 } else {
505 assert_eq!(path_to_file_url(r"/a\b/m.f.js"), "file:///a%5Cb/m.f.js");
506 assert_eq!(
507 file_url_to_path("file:///a%5Cb/m.f.js"),
508 Ok(r"/a\b/m.f.js".to_string())
509 );
510 }
511 assert_eq!(
512 file_url_to_path("file:///a/b%20c"),
513 Ok("/a/b c".to_string())
514 );
515 assert_eq!(
516 file_url_to_path("file:///C:/a"),
517 Ok(if cfg!(windows) { "C:/a" } else { "/C:/a" }.to_string())
518 );
519 assert_eq!(file_url_to_path("file:///%C3%A9"), Ok("/é".to_string()));
520 let code = |r: Result<String, IoError>| match r {
521 Err(info) => (info.code, info.message),
522 other => panic!("{other:?}"),
523 };
524 assert_eq!(code(file_url_to_path("http://a/b")).0, None);
525 assert_eq!(
526 code(file_url_to_path("file://host/a")).0,
527 Some("ERR_INVALID_FILE_URL_HOST".into())
528 );
529 assert_eq!(
530 code(file_url_to_path("file:///a%2Fb")).0,
531 Some("ERR_INVALID_FILE_URL_PATH".into())
532 );
533 assert_eq!(
534 code(file_url_to_path("file:///a%ff")).0,
535 Some("ERR_INVALID_FILE_URL_PATH".into())
536 );
537 }
538
539 #[test]
540 fn encoded_backslashes_follow_native_separator_rules() {
541 for (escape, escaped_percent) in [("%5C", "%255C"), ("%5c", "%255c")] {
542 let url = format!("file:///C:/safe{escape}sub/main.f.js");
543 if cfg!(windows) {
544 let error = file_url_to_path(&url).unwrap_err();
545 assert_eq!(error.code.as_deref(), Some("ERR_INVALID_FILE_URL_PATH"));
546 assert_eq!(
547 error.message,
548 "File URL path must not include encoded \\ or / characters"
549 );
550 } else {
551 assert_eq!(file_url_to_path(&url).unwrap(), r"/C:/safe\sub/main.f.js");
552 }
553 let url = format!("file:///C:/safe{escaped_percent}sub/main.f.js");
555 let root = if cfg!(windows) { "C:/" } else { "/C:/" };
556 assert_eq!(
557 file_url_to_path(&url).unwrap(),
558 format!("{root}safe{escape}sub/main.f.js")
559 );
560 }
561 }
562
563 #[cfg(windows)]
566 #[test]
567 fn windows_parent_urls_refuse_encoded_backslashes() {
568 let module = resolve_file_module("src/lib.rs", None).unwrap();
569 let directory = module.id.strip_suffix("/src/lib.rs").unwrap();
570 for escape in ["%5C", "%5c"] {
571 let parent = format!("{directory}{escape}src/lib.rs");
572 for name in ["", "./lib.rs"] {
573 let error = resolve_file_module(name, Some(&parent)).unwrap_err();
574 assert_eq!(error.code.as_deref(), Some("ERR_INVALID_FILE_URL_PATH"));
575 }
576 }
577 }
578
579 #[test]
580 fn refused_specifiers() {
581 let message = |name: &str| match resolve_file_module(name, Some("file:///a/b.f.js")) {
582 Err(info) => info.message,
583 other => panic!("{other:?}"),
584 };
585 assert_eq!(message("x:y"), "invalid module specifier");
586 assert_eq!(message("a%2Fb"), "invalid module specifier");
587 assert_eq!(message("//x/y"), "invalid module specifier");
588 assert_eq!(message("./a?b"), "invalid module specifier");
589 }
590
591 #[test]
594 fn authorities_are_refused_before_dot_reduction() {
595 for parent in ["file:///missing/main.f.js", "file:///C:/missing/main.f.js"] {
596 for name in [
597 "//",
598 "///tmp/dep.f.js",
599 "////tmp/dep.f.js",
600 "//localhost/tmp/dep.f.js",
601 "//LOCALHOST/tmp/dep.f.js",
602 "//%6cocalhost/tmp/dep.f.js",
603 "//host/../../Cargo.toml",
604 "//host/%2e%2e/%2e%2e/Cargo.toml",
605 "//localhost/../../Cargo.toml",
606 "///../../Cargo.toml",
607 ] {
608 let error = resolve_file_module(name, Some(parent)).unwrap_err();
609 assert_eq!(error.code, None, "{name:?}");
610 assert_eq!(error.message, "invalid module specifier", "{name:?}");
611 }
612 }
613 }
614
615 #[test]
617 fn legacy_drive_markers_are_refused_before_dot_reduction() {
618 for parent in ["file:///missing/main.f.js", "file:///D:/missing/main.f.js"] {
619 for name in [
620 "/C|/dep.f.js",
621 "/z|/dep.f.js",
622 "C|/dep.f.js",
623 "C|",
624 "/C|",
625 "/C|/../dep.f.js",
626 "/C|/%2e%2e/dep.f.js",
627 "C|/../../dep.f.js",
628 "/./C|/dep.f.js",
629 "/%2e/C|/dep.f.js",
630 "/gone/../C|/dep.f.js",
631 "/gone/%2e%2e/C|/../dep.f.js",
632 "../C|/dep.f.js",
633 "./C|/dep.f.js",
634 ] {
635 assert!(decode_specifier(name).is_some(), "{name:?}");
636 let error = resolve_file_module(name, Some(parent)).unwrap_err();
637 assert_eq!(error.code, None, "{name:?}");
638 assert_eq!(error.message, "invalid module specifier", "{name:?}");
639 }
640 }
641 }
642
643 #[test]
644 fn encoded_and_non_drive_names_are_not_raw_drive_markers() {
645 for name in [
646 "",
647 "/",
648 "/CC|/dep.f.js",
649 "/1|/dep.f.js",
650 "/é|/dep.f.js",
651 "/a|b/dep.f.js",
652 "/%43|/dep.f.js",
653 "/C%7C/dep.f.js",
654 "/%43%7c/dep.f.js",
655 "/%2543|/dep.f.js",
656 ] {
657 assert!(!has_legacy_drive_marker(name), "{name:?}");
658 }
659 assert_eq!(
661 decode_specifier("/C|/dep.f.js"),
662 decode_specifier("/%43|/dep.f.js")
663 );
664 }
665
666 #[test]
667 fn raw_url_preprocessing_is_refused_before_resolution() {
668 let refused = |name: &str| {
669 assert!(needs_url_preprocessing(name), "{name:?}");
670 for parent in ["file:///missing/main.f.js", "file:///C:/missing/main.f.js"] {
671 let error = resolve_file_module(name, Some(parent)).unwrap_err();
672 assert_eq!(error.code, None, "{name:?}");
673 assert_eq!(error.message, "invalid module specifier", "{name:?}");
674 }
675 };
676 for byte in 0..=b' ' {
677 let c = char::from(byte);
678 refused(&format!("{c}./dep"));
679 refused(&format!("./dep{c}"));
680 }
681 for name in ["./a\tb", "./a\nb", "./a\rb", " /C|/dep", "/\t/host/x"] {
682 refused(name);
683 }
684 }
685
686 #[test]
687 fn encoded_and_internal_filename_characters_are_not_preprocessed() {
688 for name in [
689 "",
690 "./dep%20",
691 "./a%09b",
692 "./a%0Ab",
693 "./a%0Db",
694 "./dep%01",
695 "./a b",
696 "./a\u{1}b",
697 "./dep\u{a0}",
698 "\u{feff}dep",
699 ] {
700 assert!(!needs_url_preprocessing(name), "{name:?}");
701 assert!(decode_specifier(name).is_some(), "{name:?}");
702 }
703 }
704
705 #[test]
706 fn entry_controls_are_distinct_from_import_preprocessing() {
707 for byte in 0..b' ' {
708 let name = format!("/root/dep{}", char::from(byte));
709 assert_eq!(
710 entry_path_needs_url_preprocessing(&name),
711 !matches!(byte, b'\0' | b'\t' | b'\n' | b'\r')
712 );
713 let internal = format!("{name}/x");
714 let encoded = format!("/root/dep%{byte:02X}");
715 assert!(!entry_path_needs_url_preprocessing(&internal));
716 assert!(!entry_path_needs_url_preprocessing(&encoded));
717 }
718 for name in ["", "/", "/root/dep ", " /root/dep", "/root/dep\u{a0}"] {
719 assert!(!entry_path_needs_url_preprocessing(name), "{name:?}");
720 }
721 }
722
723 #[cfg(unix)]
724 #[test]
725 fn non_utf8_path_text_is_not_replaced() {
726 use std::{ffi::OsStr, os::unix::ffi::OsStrExt};
727 let path = Path::new(OsStr::from_bytes(b"bad-\xff"));
728 assert_eq!(
729 path_text(path).unwrap_err().kind(),
730 std::io::ErrorKind::InvalidData
731 );
732 assert_eq!(path_text(Path::new("\u{fffd}")).unwrap(), "\u{fffd}");
733 }
734
735 #[cfg(windows)]
736 #[test]
737 fn unpaired_utf16_paths_are_not_replaced() {
738 use std::{ffi::OsString, os::windows::ffi::OsStringExt};
739 let path = PathBuf::from(OsString::from_wide(&[0xd800]));
740 assert_eq!(
741 path_text(&path).unwrap_err().kind(),
742 std::io::ErrorKind::InvalidData
743 );
744 assert_eq!(path_text(Path::new("\u{fffd}")).unwrap(), "\u{fffd}");
745 }
746
747 #[cfg(unix)]
749 mod files {
750 use super::*;
751 use crate::scratch::Scratch;
752 use std::os::unix::fs::symlink;
753
754 #[test]
755 fn from_a_path_and_from_a_parent() {
756 let dir = Scratch::new("resolve-tree");
757 fs::create_dir_all(dir.at("src/lib")).unwrap();
758 fs::write(dir.at("src/main.f.js"), "").unwrap();
759 fs::write(dir.at("src/lib/a b.f.js"), "").unwrap();
760 fs::write(dir.at("top.f.js"), "").unwrap();
761 let main = resolve_file_module(&dir.at("src/./lib/../main.f.js"), None).unwrap();
762 assert_eq!(main.path, dir.at("src/main.f.js"));
763 assert_eq!(main.id, path_to_file_url(&main.path));
764 let ids = |name: &str| resolve_file_module(name, Some(&main.id)).unwrap();
765 assert_eq!(ids("./lib/a%20b.f.js").path, dir.at("src/lib/a b.f.js"));
766 assert_eq!(
767 ids("./lib/a%20b.f.js").id,
768 format!("file://{}", percent_encode(&dir.at("src/lib/a b.f.js")))
769 );
770 assert_eq!(ids("../top.f.js").path, dir.at("top.f.js"));
771 assert_eq!(ids("./lib/../../top.f.js").path, dir.at("top.f.js"));
772 assert_eq!(ids(".//lib/a%20b.f.js").path, dir.at("src/lib/a b.f.js"));
773 assert_eq!(ids(&dir.at("top.f.js")[..]).path, dir.at("top.f.js"));
774 assert_eq!(ids("").path, main.path);
775 assert_eq!(ids("%2e/main.f.js").path, main.path);
776 }
777
778 #[test]
779 fn trailing_separators_do_not_turn_files_into_directories() {
780 let dir = Scratch::new("resolve-trailing");
781 fs::write(dir.at("dep.f.js"), "").unwrap();
782 fs::create_dir(dir.at("folder")).unwrap();
783 let parent = path_to_file_url(&dir.at("main.f.js"));
784 for name in [
785 "./dep.f.js/",
786 "./dep.f.js//",
787 "./dep.f.js/.",
788 "./dep.f.js/%2e",
789 "./dep.f.js/child/..",
790 ] {
791 for specifier in [name.to_string(), dir.at(name)] {
792 let error = resolve_file_module(&specifier, Some(&parent)).unwrap_err();
793 assert_eq!(error.code.as_deref(), Some("ENOTDIR"), "{specifier:?}");
794 }
795 }
796 let error = resolve_file_module(&dir.at("dep.f.js/"), None).unwrap_err();
797 assert_eq!(error.code.as_deref(), Some("ENOTDIR"));
798 let folder = resolve_file_module(&dir.at("folder"), None).unwrap();
799 assert_eq!(
800 resolve_file_module("./folder/", Some(&parent)).unwrap(),
801 folder
802 );
803 assert_eq!(
804 resolve_file_module("./folder/.", Some(&parent)).unwrap(),
805 folder
806 );
807 symlink(dir.at("folder"), dir.at("folder-link")).unwrap();
809 assert_eq!(
810 resolve_file_module("./folder-link/", Some(&parent)).unwrap(),
811 folder
812 );
813 let missing = resolve_file_module("./missing/", Some(&parent)).unwrap_err();
814 assert_eq!(missing.code.as_deref(), Some("ENOENT"));
815 let root = resolve_file_module(&dir.at(""), None).unwrap();
816 assert_eq!(resolve_file_module(".", Some(&parent)).unwrap(), root);
817 assert_eq!(
818 resolve_file_module("./folder/..", Some(&parent)).unwrap(),
819 root
820 );
821 }
822
823 #[test]
824 fn posix_backslashes_do_not_alias_path_separators() {
825 let dir = Scratch::new("resolve-backslash");
826 for tree in [r"a\b", "a/b"] {
827 fs::create_dir_all(dir.at(tree)).unwrap();
828 fs::write(dir.at(&format!("{tree}/main.f.js")), "").unwrap();
829 fs::write(dir.at(&format!("{tree}/dep.f.js")), "").unwrap();
830 }
831 let literal = resolve_file_module(&dir.at(r"a\b/main.f.js"), None).unwrap();
832 let separated = resolve_file_module(&dir.at("a/b/main.f.js"), None).unwrap();
833 assert_eq!(literal.path, dir.at(r"a\b/main.f.js"));
834 assert_ne!(literal.id, separated.id);
835 assert!(literal.id.ends_with("/a%5Cb/main.f.js"));
836 assert_eq!(file_url_to_path(&literal.id).unwrap(), literal.path);
837 let dep = resolve_file_module("./dep.f.js", Some(&literal.id)).unwrap();
838 assert_eq!(dep.path, dir.at(r"a\b/dep.f.js"));
839 assert_eq!(
840 resolve_file_module("./main.f.js", Some(&dep.id)).unwrap(),
841 literal
842 );
843 }
844
845 #[test]
846 fn escaped_drive_bar_names_remain_literal() {
847 let dir = Scratch::new("resolve-drive-bar");
848 fs::create_dir(dir.at("C|")).unwrap();
849 fs::write(dir.at("C|/dep.f.js"), "").unwrap();
850 let literal = resolve_file_module(&dir.at("C|/dep.f.js"), None).unwrap();
852 assert!(literal.id.ends_with("/C%7C/dep.f.js"));
853 let parent = path_to_file_url(&dir.at("main.f.js"));
854 for name in ["./%43|/dep.f.js", "./C%7C/dep.f.js", "./%43%7c/dep.f.js"] {
855 assert_eq!(resolve_file_module(name, Some(&parent)).unwrap(), literal);
856 }
857 assert_eq!(
858 resolve_file_module("./dep.f.js", Some(&literal.id)).unwrap(),
859 literal
860 );
861 let error = resolve_file_module("./C|/dep.f.js", Some(&parent)).unwrap_err();
862 assert_eq!(error.message, "invalid module specifier");
863 }
864
865 #[test]
866 fn entry_dot_segments_are_reduced_before_following_symlinks() {
867 let dir = Scratch::new("resolve-lexical");
868 fs::create_dir_all(dir.at("target/nested")).unwrap();
869 fs::write(dir.at("main.f.js"), "").unwrap();
870 fs::write(dir.at("target/main.f.js"), "").unwrap();
871 symlink(dir.at("target/nested"), dir.at("link")).unwrap();
872 let module = resolve_file_module(&dir.at("link/../main.f.js"), None).unwrap();
873 assert_eq!(module.path, dir.at("main.f.js"));
874 }
875
876 #[cfg(target_os = "linux")]
879 #[test]
880 fn non_utf8_targets_do_not_alias_replacement_characters() {
881 use std::{ffi::OsStr, os::unix::ffi::OsStrExt};
882 let dir = Scratch::new("resolve-non-utf8");
883 let raw = dir.path().join(OsStr::from_bytes(b"bad-\xff"));
884 fs::create_dir(&raw).unwrap();
885 fs::write(raw.join("dep.f.js"), "original").unwrap();
886 fs::create_dir(dir.at("bad-\u{fffd}")).unwrap();
887 let replacement = dir.at("bad-\u{fffd}/dep.f.js");
888 fs::write(&replacement, "replacement").unwrap();
889 symlink(raw.join("dep.f.js"), dir.at("link.f.js")).unwrap();
890 symlink(&raw, dir.at("link-dir")).unwrap();
891 assert_eq!(
892 path_text(&raw).unwrap_err().kind(),
893 std::io::ErrorKind::InvalidData
894 );
895 let parent = path_to_file_url(&dir.at("main.f.js"));
896 for name in ["link.f.js", "link-dir/dep.f.js"] {
897 let entry = resolve_file_module(&dir.at(name), None).unwrap_err();
898 let import = resolve_file_module(name, Some(&parent)).unwrap_err();
899 for error in [entry, import] {
900 assert!(
901 error
902 .message
903 .contains("non-UTF-8 module paths are not supported")
904 );
905 }
906 }
907 let valid = resolve_file_module(&replacement, None).unwrap();
908 assert_eq!(valid.path, replacement);
909 assert_eq!(file_url_to_path(&valid.id).unwrap(), valid.path);
910 assert_eq!(fs::read(&valid.path).unwrap(), b"replacement");
911 assert_eq!(fs::read(dir.at("link.f.js")).unwrap(), b"original");
912 }
913
914 #[test]
915 fn escaped_whitespace_names_remain_literal() {
916 let dir = Scratch::new("resolve-url-whitespace");
917 let parent = path_to_file_url(&dir.at("main.f.js"));
918 for (name, encoded, plain) in [
919 ("dep ", "dep%20", "dep"),
920 ("a\tb", "a%09b", "ab"),
921 ("a\nb", "a%0Ab", "ab"),
922 ("a\rb", "a%0Db", "ab"),
923 ] {
924 fs::write(dir.at(name), "literal").unwrap();
925 fs::write(dir.at(plain), "plain").unwrap();
926 let literal = resolve_file_module(&dir.at(name), None).unwrap();
927 let other = resolve_file_module(plain, Some(&parent)).unwrap();
928 assert_ne!(literal.id, other.id);
929 assert_eq!(file_url_to_path(&literal.id).unwrap(), literal.path);
930 assert_eq!(
931 resolve_file_module(encoded, Some(&parent)).unwrap(),
932 literal
933 );
934 let error = resolve_file_module(name, Some(&parent)).unwrap_err();
935 assert_eq!(error.message, "invalid module specifier");
936 }
937 for name in ["a b", "dep\u{a0}"] {
938 fs::write(dir.at(name), "literal").unwrap();
939 let literal = resolve_file_module(&dir.at(name), None).unwrap();
940 assert_eq!(resolve_file_module(name, Some(&parent)).unwrap(), literal);
941 }
942 }
943
944 #[test]
945 fn terminal_entry_controls_do_not_load_a_different_file() {
946 let dir = Scratch::new("resolve-entry-controls");
947 fs::write(dir.at("dep"), "plain").unwrap();
948 let plain = resolve_file_module(&dir.at("dep"), None).unwrap();
949 let parent = path_to_file_url(&dir.at("main.f.js"));
950 for (name, encoded) in [("dep\u{1}", "dep%01"), ("dep\u{1f}", "dep%1F")] {
951 fs::write(dir.at(name), "literal").unwrap();
952 for suffix in ["", "/.", "/child/.."] {
953 let entry = dir.at(&format!("{name}{suffix}"));
954 let error = resolve_file_module(&entry, None).unwrap_err();
955 assert_eq!(error.code, None);
956 assert_eq!(
957 error.message,
958 "entry paths with terminal C0 controls are not supported"
959 );
960 }
961 let literal = resolve_file_module(encoded, Some(&parent)).unwrap();
962 assert_eq!(literal.path, dir.at(name));
963 assert_ne!(literal.id, plain.id);
964 assert_eq!(file_url_to_path(&literal.id).unwrap(), literal.path);
965 assert_eq!(fs::read(&literal.path).unwrap(), b"literal");
966 }
967 fs::remove_file(&plain.path).unwrap();
969 let error = resolve_file_module(&dir.at("dep\u{1}"), None).unwrap_err();
970 assert_eq!(
971 error.message,
972 "entry paths with terminal C0 controls are not supported"
973 );
974 for name in ["dep ", "dep\t", "dep\n", "dep\r", "de\u{1}p", "dep%01"] {
975 let path = dir.at(name);
976 fs::write(&path, "literal").unwrap();
977 assert_eq!(resolve_file_module(&path, None).unwrap().path, path);
978 }
979 }
980
981 #[test]
982 fn a_link_resolves_to_its_target() {
983 let dir = Scratch::new("resolve-link");
984 fs::create_dir_all(dir.at("real")).unwrap();
985 fs::write(dir.at("real/m.f.js"), "").unwrap();
986 symlink(dir.at("real"), dir.at("link")).unwrap();
987 let via = resolve_file_module(&dir.at("link/m.f.js"), None).unwrap();
988 assert_eq!(via.path, dir.at("real/m.f.js"));
989 let next = resolve_file_module("./m.f.js", Some(&via.id)).unwrap();
991 assert_eq!(next, via);
992 }
993
994 #[test]
995 fn a_missing_module_is_enoent() {
996 let dir = Scratch::new("resolve-missing");
997 let Err(info) = resolve_file_module(&dir.at("none.f.js"), None) else {
998 panic!("a missing module resolved")
999 };
1000 assert_eq!(info.code, Some("ENOENT".into()));
1001 assert!(
1002 info.message
1003 .ends_with(&format!("realpath '{}'", dir.at("none.f.js"))),
1004 "{}",
1005 info.message
1006 );
1007 let Err(info) =
1008 resolve_file_module("./none.f.js", Some(&path_to_file_url(&dir.at("a.f.js"))))
1009 else {
1010 panic!("a missing module resolved")
1011 };
1012 assert_eq!(info.code, Some("ENOENT".into()));
1013 }
1014
1015 #[test]
1016 fn a_relative_path_is_from_the_working_directory() {
1017 let here = std::env::current_dir().unwrap();
1018 let module = resolve_file_module("Cargo.toml", None).unwrap();
1019 assert_eq!(
1020 module.path,
1021 fs::canonicalize(here.join("Cargo.toml"))
1022 .unwrap()
1023 .to_string_lossy()
1024 );
1025 }
1026 }
1027}