Skip to main content

nanvm_effects_node/
resolve.rs

1//! Resolving a module to the file that holds it, as `resolveFileModule` of
2//! `fjs/effects/node/module.mjs` does: a path, or a specifier relative to the
3//! file that imports it, becomes the real path of the file and its identity, a
4//! `file:` URL.
5//!
6//! The specifier rules are `fjs/path/import`'s: no scheme, backslash, query or
7//! fragment; dot segments cancel before anything is decoded; what survives is
8//! percent-decoded, strictly, into names that hold no separator and no colon.
9//! The identity is `pathToFileURL` of the real path, whose percent-encoding is
10//! the set Node 22 encodes: the controls, space, `"`, `#`, `%`, `<`, `>`, `?`,
11//! `[`, `\`, `]`, `^`, `` ` ``, `{`, `|`, `}`, `~`, and every byte that is not
12//! ASCII. Native paths must be Unicode; imports requiring raw-character URL
13//! preprocessing are refused. See `../todo/module-path-encoding.md`.
14
15use crate::files::IoError;
16use std::{
17    fs,
18    path::{Component, Path, PathBuf},
19};
20
21/// The module a specifier names: the real path of its file and its identity,
22/// a `file:` URL.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct FileModule {
25    pub id: String,
26    pub path: String,
27}
28
29/// `%XX` escapes read as the bytes they are, and the bytes as UTF-8; `None`
30/// where an escape is cut short or not hexadecimal, or the bytes are not UTF-8.
31pub 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
48/// A path as the text of a URL's path: the bytes `pathToFileURL` escapes, as `%XX`.
49pub 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
76/// The kind of one URL path segment, read while it is still encoded: only the
77/// exact dot spellings are structural.
78enum 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
92/// One surviving segment as a file name, or `None`: decoded, it may hold no
93/// separator, no NUL and no colon.
94fn segment(raw: &str) -> Option<String> {
95    let decoded = percent_decode(raw)?;
96    (!decoded.contains(['/', '\\', '\0', ':'])).then_some(decoded)
97}
98
99/// `decode` of `fjs/path/import`: an import specifier as whether it is rooted
100/// and the portable names that follow, joined by `/`, or `None` for one that
101/// names no file. A `..` that has nothing to cancel stays in a relative
102/// specifier and goes in a rooted one. Keeping the root apart from the names
103/// is where this differs: `.//x` is relative here, as a URL reads it, where
104/// the JavaScript text of `.//x` is `/x`, a root.
105pub 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
128/// `fileURLToPath`, for a URL this runner made or one like it: the path, where
129/// the URL names one on this machine.
130pub 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    // Decode only after rejecting native separators. A POSIX backslash is
151    // filename data, but on Windows accepting it would change the directory.
152    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    // Only Windows reads `/C:/a` as the drive path `C:/a`. On POSIX the
158    // leading slash and the colon are part of the native absolute path.
159    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
169/// `pathToFileURL` of an absolute path.
170pub 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
184/// Keep native drive paths, stripping only their extended-length prefix.
185/// UNC and device paths need URL-authority support that this resolver lacks:
186/// refuse them rather than returning a plausible but incorrect module identity.
187/// See `nanvm/effects-node/todo/unc-module-identities.md`.
188/// This lexical helper is tested on every platform, without accessing a share.
189fn 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
208/// Module identities must not replace native filename bytes with U+FFFD.
209/// Apply the same lossless conversion to cwd-derived and canonical paths.
210fn 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
219/// Resolve native roots before reducing dot segments, without following links.
220/// `absolute` handles Windows root-relative and drive-relative paths; native
221/// components keep POSIX backslashes and colons literal. Like `pathToFileURL`,
222/// collapse `..` before realpath, but keep an explicit trailing separator.
223fn 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    // `absolute` may have supplied a UNC working directory, even for a local
245    // relative input. Never turn it into a URL without authority support.
246    if cfg!(windows) {
247        windows_path(&result).map(str::to_string)
248    } else {
249        Ok(result)
250    }
251}
252
253/// A URL ending in a slash or a dot segment still names a directory after
254/// dot-segment reduction. An empty specifier instead names the importer itself.
255fn directory_specifier(name: &str) -> bool {
256    name.ends_with('/') || !matches!(kind(name.rsplit('/').next().unwrap_or("")), Kind::Keep)
257}
258
259/// Raw `C|` components can become URL drive markers, even after dot reduction.
260/// Refuse them conservatively until URL-aware drive handling is implemented.
261/// Inspect raw components: `%43|` and `C%7C` are literal names, not markers.
262/// See `nanvm/effects-node/todo/local-file-url-authorities.md`.
263fn 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
268/// Refuse raw imports that WHATWG preprocessing would change. Percent escapes
269/// and literal entry paths are not preprocessed; internal spaces stay literal.
270/// See `../todo/module-path-encoding.md` for the deferred URL behavior.
271fn needs_url_preprocessing(name: &str) -> bool {
272    name.trim_matches(|c: char| c <= ' ') != name || name.contains(['\t', '\n', '\r'])
273}
274
275/// Node 22's entry conversion drops terminal C0 except NUL, tab, LF and CR,
276/// which it escapes first. Check the normalized path, including `dep<U+0001>/.`.
277/// Spaces, encoded imports and controls inside native filenames stay literal.
278fn entry_path_needs_url_preprocessing(path: &str) -> bool {
279    path.ends_with(|c: char| c < ' ' && !matches!(c, '\0' | '\t' | '\n' | '\r'))
280}
281
282/// The real native path, without a Windows drive's extended-length prefix.
283/// Check again after canonicalization: a local link may resolve to a UNC path.
284fn real_path(path: &str) -> std::io::Result<String> {
285    // Some hosts canonicalize a regular file even with a trailing separator.
286    // Enforce the directory requirement before canonicalization can erase it.
287    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
298/// The slash-separated directory of a decoded file URL path.
299fn directory(path: &str) -> &str {
300    path.rfind('/').map_or("", |i| &path[..i])
301}
302
303/// The module `name` names, from the file `parent`'s identity, or from the
304/// working directory where there is none: the real path and its identity.
305pub 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            // An authority is not a path segment: `//host/../../x` must not
311            // lose its host during dot reduction and turn into a local import.
312            // Empty and localhost authorities remain unsupported too; see
313            // `nanvm/effects-node/todo/local-file-url-authorities.md`.
314            // Raw drive markers must not be erased by dot reduction either,
315            // or confused with percent-encoded literal names.
316            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                // On Windows a rooted import keeps the importer's drive,
326                // which need not be the current working directory's drive.
327                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    /// What `decode` of `fjs/path/import` answers, by node, as (rooted, names);
359    /// `None` is its `null`.
360    #[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    /// Root-relative entries must not be prefixed with the whole cwd. No
421    /// second drive, share, symlink privilege or cwd mutation is required.
422    #[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        // Node 22: the characters `pathToFileURL` escapes, one of each kind.
441        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    /// No share or symlink privilege is needed: refusal precedes filesystem IO.
481    #[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            // An escaped percent sign is decoded once, not into a separator.
554            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    /// Use an existing file, so the old conversion would return a module
564    /// rather than an incidental filesystem error. No share is required.
565    #[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    /// Refuse unsupported authorities before decoding can erase the host.
592    /// These checks need neither a local fixture nor a network share.
593    #[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    /// Refusal happens before URL decoding or filesystem access on every host.
616    #[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        // Decoding first would erase the distinction the URL parser observes.
660        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    /// Against a real tree, with symbolic links: a module is the real file.
748    #[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            // The directory check follows a link, but keeps missing-path errors.
808            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            // An entry is a literal path, not an import specifier.
851            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        // APFS rejects non-UTF-8 names at creation; use Linux for the real
877        // collision fixture. The lossless conversion is tested on every OS.
878        #[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            // Refusal does not depend on the plain lookalike existing.
968            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            // Imports are resolved from the importer's real directory.
990            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}