Skip to main content

uv_fs/
path.rs

1use std::borrow::Cow;
2use std::ffi::OsString;
3use std::path::{Component, Path, PathBuf, Prefix};
4use std::sync::LazyLock;
5
6use either::Either;
7use path_slash::PathExt;
8
9/// The current working directory.
10#[expect(clippy::print_stderr)]
11pub static CWD: LazyLock<PathBuf> = LazyLock::new(|| {
12    std::env::current_dir().unwrap_or_else(|_e| {
13        eprintln!("Current directory does not exist");
14        std::process::exit(1);
15    })
16});
17
18pub trait Simplified {
19    /// Simplify a [`Path`].
20    ///
21    /// On Windows, this will strip the `\\?\` prefix from paths. On other platforms, it's a no-op.
22    fn simplified(&self) -> &Path;
23
24    /// Render a [`Path`] for display.
25    ///
26    /// On Windows, this will strip the `\\?\` prefix from paths. On other platforms, it's
27    /// equivalent to [`std::path::Display`].
28    fn simplified_display(&self) -> impl std::fmt::Display;
29
30    /// Canonicalize a path, stripping the `\\?\` prefix on Windows when possible.
31    fn simple_canonicalize(&self) -> std::io::Result<PathBuf>;
32
33    /// Render a [`Path`] for user-facing display.
34    ///
35    /// Like [`simplified_display`], but relativizes the path against the current working directory.
36    fn user_display(&self) -> impl std::fmt::Display;
37
38    /// Render a [`Path`] for user-facing display, where the [`Path`] is relative to a base path.
39    ///
40    /// If the [`Path`] is not relative to the base path, will attempt to relativize the path
41    /// against the current working directory.
42    fn user_display_from(&self, base: impl AsRef<Path>) -> impl std::fmt::Display;
43
44    /// Render a [`Path`] for user-facing display using a portable representation.
45    ///
46    /// Like [`user_display`], but uses a portable representation for relative paths.
47    fn portable_display(&self) -> impl std::fmt::Display;
48}
49
50impl<T: AsRef<Path>> Simplified for T {
51    fn simplified(&self) -> &Path {
52        dunce::simplified(self.as_ref())
53    }
54
55    fn simplified_display(&self) -> impl std::fmt::Display {
56        dunce::simplified(self.as_ref()).display()
57    }
58
59    fn simple_canonicalize(&self) -> std::io::Result<PathBuf> {
60        dunce::canonicalize(self.as_ref())
61    }
62
63    fn user_display(&self) -> impl std::fmt::Display {
64        let path = dunce::simplified(self.as_ref());
65
66        // If current working directory is root, display the path as-is.
67        if CWD.ancestors().nth(1).is_none() {
68            return path.display();
69        }
70
71        // Attempt to strip the current working directory, then the canonicalized current working
72        // directory, in case they differ.
73        let path = path.strip_prefix(CWD.simplified()).unwrap_or(path);
74
75        if path.as_os_str() == "" {
76            // Avoid printing an empty string for the current directory
77            return Path::new(".").display();
78        }
79
80        path.display()
81    }
82
83    fn user_display_from(&self, base: impl AsRef<Path>) -> impl std::fmt::Display {
84        let path = dunce::simplified(self.as_ref());
85
86        // If current working directory is root, display the path as-is.
87        if CWD.ancestors().nth(1).is_none() {
88            return path.display();
89        }
90
91        // Attempt to strip the base, then the current working directory, then the canonicalized
92        // current working directory, in case they differ.
93        let path = path
94            .strip_prefix(base.as_ref())
95            .unwrap_or_else(|_| path.strip_prefix(CWD.simplified()).unwrap_or(path));
96
97        if path.as_os_str() == "" {
98            // Avoid printing an empty string for the current directory
99            return Path::new(".").display();
100        }
101
102        path.display()
103    }
104
105    fn portable_display(&self) -> impl std::fmt::Display {
106        let path = dunce::simplified(self.as_ref());
107
108        // Attempt to strip the current working directory, then the canonicalized current working
109        // directory, in case they differ.
110        let path = path.strip_prefix(CWD.simplified()).unwrap_or(path);
111
112        // Use a portable representation for relative paths.
113        path.to_slash()
114            .map(Either::Left)
115            .unwrap_or_else(|| Either::Right(path.display()))
116    }
117}
118
119pub trait PythonExt {
120    /// Render a [`Path`] as a Python expression that evaluates to a [`str`].
121    ///
122    /// On Unix, paths are arbitrary byte strings, so this uses [`os.fsdecode()`] to produce a
123    /// surrogate-escaped `str`. On Windows, paths are encoded as UTF-16, so this returns a `str`
124    /// literal that preserves the original UTF-16 code units.
125    ///
126    /// [`str`]: https://docs.python.org/3/library/stdtypes.html#text-sequence-type-str
127    /// [`os.fsdecode()`]: https://docs.python.org/3/library/os.html#os.fsdecode
128    fn escape_for_python(&self) -> String;
129}
130
131impl<T: AsRef<Path>> PythonExt for T {
132    fn escape_for_python(&self) -> String {
133        escape_path_for_python(self)
134    }
135}
136
137/// Serialize a path as a Python expression that evaluates to a `str`.
138///
139/// Note: Due to the fun quirks *nix paths, how python handles them, and expectations of things that
140/// might consume those paths, this produces an expression wrapped in `__import__("os").fsdecode()`.
141#[cfg(unix)]
142fn escape_path_for_python<P: AsRef<Path>>(path: P) -> String {
143    use std::os::unix::ffi::OsStrExt;
144    format!(
145        r#"__import__("os").fsdecode(b"{}")"#,
146        path.as_ref().as_os_str().as_bytes().escape_ascii()
147    )
148}
149
150/// Serialize a path as a Python expression that evaluates to a `str`.
151#[cfg(windows)]
152fn escape_path_for_python<P: AsRef<Path>>(path: P) -> String {
153    use std::fmt::Write;
154    use std::os::windows::ffi::OsStrExt;
155
156    let mut literal = String::new();
157    literal.push('"');
158    for character in char::decode_utf16(path.as_ref().as_os_str().encode_wide()) {
159        match character {
160            Ok(character) if character.is_ascii() => {
161                literal.extend((character as u8).escape_ascii().map(char::from));
162            }
163            // `is_control` also covers the non-ASCII C1 range (`U+0080..=U+009F`).
164            Ok(character) if character.is_control() => {
165                let _ = write!(literal, r"\u{:04x}", u32::from(character));
166            }
167            Ok(character) => literal.push(character),
168            Err(error) => {
169                let _ = write!(literal, r"\u{:04x}", error.unpaired_surrogate());
170            }
171        }
172    }
173    literal.push('"');
174    literal
175}
176
177/// Normalize the `path` component of a URL for use as a file path.
178///
179/// For example, on Windows, transforms `C:\Users\ferris\wheel-0.42.0.tar.gz` to
180/// `/C:/Users/ferris/wheel-0.42.0.tar.gz`.
181///
182/// On other platforms, this is a no-op.
183pub fn normalize_url_path(path: &str) -> Cow<'_, str> {
184    // Apply percent-decoding to the URL.
185    let path = percent_encoding::percent_decode_str(path)
186        .decode_utf8()
187        .unwrap_or(Cow::Borrowed(path));
188
189    // Return the path.
190    if cfg!(windows) {
191        Cow::Owned(
192            path.strip_prefix('/')
193                .unwrap_or(&path)
194                .replace('/', std::path::MAIN_SEPARATOR_STR),
195        )
196    } else {
197        path
198    }
199}
200
201/// Normalize a path, removing things like `.` and `..`.
202///
203/// Source: <https://github.com/rust-lang/cargo/blob/b48c41aedbd69ee3990d62a0e2006edbb506a480/crates/cargo-util/src/paths.rs#L76C1-L109C2>
204///
205/// CAUTION: Assumes that the path is already absolute.
206///
207/// CAUTION: This does not resolve symlinks (unlike
208/// [`std::fs::canonicalize`]). This may cause incorrect or surprising
209/// behavior at times. This should be used carefully. Unfortunately,
210/// [`std::fs::canonicalize`] can be hard to use correctly, since it can often
211/// fail, or on Windows returns annoying device paths.
212///
213/// # Errors
214///
215/// When a relative path is provided with `..` components that extend beyond the base directory.
216/// For example, `./a/../../b` cannot be normalized because it escapes the base directory.
217pub fn normalize_absolute_path(path: &Path) -> Result<PathBuf, std::io::Error> {
218    let mut components = path.components().peekable();
219    let mut ret = components
220        .next_if_map_mut(|component| match component {
221            Component::Prefix(..) => Some(PathBuf::from(component.as_os_str())),
222            _ => None,
223        })
224        .unwrap_or_default();
225
226    for component in components {
227        match component {
228            Component::Prefix(..) => unreachable!(),
229            Component::RootDir => {
230                ret.push(component.as_os_str());
231            }
232            Component::CurDir => {}
233            Component::ParentDir => {
234                if !ret.pop() {
235                    return Err(std::io::Error::new(
236                        std::io::ErrorKind::InvalidInput,
237                        format!(
238                            "cannot normalize a relative path beyond the base directory: {}",
239                            path.display()
240                        ),
241                    ));
242                }
243            }
244            Component::Normal(c) => {
245                ret.push(c);
246            }
247        }
248    }
249    Ok(ret)
250}
251
252/// Returns `false` if [`Path::components`] discarded any bytes from `path`, without allocating.
253///
254/// [`Path::components`] silently strips interior `.` segments, repeated separators, and
255/// trailing separators. If the `path` length differs from the computed byte length from
256/// `path.components().collect()`, the path isn't normalized (or there is a special case we handle
257/// here, in which case we perform a redundant normalization pass later).
258fn path_equals_components(path: &Path) -> bool {
259    // We count the length in bytes; the encoding scheme doesn't matter as we count bytes in
260    // both expected and the input path
261    let mut expected_len = 0;
262    let mut next_needs_separator = false;
263    for component in path.components() {
264        let bytes = component.as_os_str().as_encoded_bytes();
265        // `PathBuf::push` inserts a separator between components unless the previous one
266        // already ends in one, or the new component is itself the root (which embeds it).
267        if next_needs_separator && !matches!(component, Component::RootDir) {
268            // Assumption: forward and backwards slashes encode with the same length.
269            expected_len += Path::new("/").as_os_str().as_encoded_bytes().len();
270        }
271        expected_len += bytes.len();
272        next_needs_separator = match component {
273            // The root dir is the slash.
274            Component::RootDir => false,
275            // Prefix has `RootDir` after it if it requires a slash.
276            Component::Prefix(_) => false,
277            _ => true,
278        };
279    }
280    expected_len == path.as_os_str().as_encoded_bytes().len()
281}
282
283/// Normalize a [`Cow`] path, removing `.`, `..`, repeated separators (`//`), and trailing slashes.
284///
285/// Paths that point to the current directory (`.` or `.\.`) are normalized to the empty path.
286///
287/// When the path is already normalized, returns it as-is without allocating.
288pub fn normalize_path<'path>(path: impl Into<Cow<'path, Path>>) -> Cow<'path, Path> {
289    let path = path.into();
290    // A path with leading `.` or `..` is not normalized.
291    if path
292        .components()
293        .any(|component| matches!(component, Component::ParentDir | Component::CurDir))
294    {
295        return Cow::Owned(normalized(&path));
296    }
297
298    // A path with non-leading `.`, repeated separators (`//`) or trailing slashes is not
299    // normalized.
300    if !path_equals_components(&path) {
301        return Cow::Owned(normalized(&path));
302    }
303
304    // Fast path: already normalized, return as-is.
305    path
306}
307
308/// Normalize `path`, returning it when it remains strictly under a non-empty `root`.
309///
310/// This comparison is lexical and does not resolve symlinks. The returned path is normalized, and
311/// equality with `root` is rejected.
312pub fn normalize_path_under(path: impl AsRef<Path>, root: impl AsRef<Path>) -> Option<PathBuf> {
313    let path = normalize_path(path.as_ref()).into_owned();
314    let root = normalize_path(root.as_ref());
315
316    if root.as_os_str().is_empty() || path.as_path() == root.as_ref() {
317        None
318    } else {
319        path.starts_with(root.as_ref()).then_some(path)
320    }
321}
322
323/// Normalize a [`Path`].
324///
325/// Unlike [`normalize_absolute_path`], this works with relative paths and does never error.
326///
327/// Note that we can theoretically go beyond the root dir here (e.g. `/usr/../../foo` becomes
328/// `/../foo`), but that's not a (correctness) problem, we will fail later with a file not found
329/// error with a path computed from the user's input.
330///
331/// # Examples
332///
333/// In: `../../workspace-git-path-dep-test/packages/c/../../packages/d`
334/// Out: `../../workspace-git-path-dep-test/packages/d`
335///
336/// In: `workspace-git-path-dep-test/packages/c/../../packages/d`
337/// Out: `workspace-git-path-dep-test/packages/d`
338///
339/// In: `./a/../../b`
340fn normalized(path: &Path) -> PathBuf {
341    let mut normalized = PathBuf::new();
342    for component in path.components() {
343        match component {
344            Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
345                // Preserve filesystem roots and regular path components.
346                normalized.push(component);
347            }
348            Component::ParentDir => {
349                match normalized.components().next_back() {
350                    None | Some(Component::ParentDir | Component::RootDir) => {
351                        // Preserve leading and above-root `..`
352                        normalized.push(component);
353                    }
354                    Some(Component::Normal(_) | Component::Prefix(_) | Component::CurDir) => {
355                        // Remove inner `..`
356                        normalized.pop();
357                    }
358                }
359            }
360            Component::CurDir => {
361                // Remove `.`
362            }
363        }
364    }
365    normalized
366}
367
368/// Compute a path describing `path` relative to `base`.
369///
370/// `lib/python/site-packages/foo/__init__.py` and `lib/python/site-packages` -> `foo/__init__.py`
371/// `lib/marker.txt` and `lib/python/site-packages` -> `../../marker.txt`
372/// `bin/foo_launcher` and `lib/python/site-packages` -> `../../../bin/foo_launcher`
373///
374/// Returns `Err` if there is no relative path between `path` and `base` (for example, if the paths
375/// are on different drives on Windows).
376pub fn relative_to(
377    path: impl AsRef<Path>,
378    base: impl AsRef<Path>,
379) -> Result<PathBuf, std::io::Error> {
380    // Normalize both paths, to avoid intermediate `..` components.
381    let path = normalize_path(path.as_ref());
382    let base = normalize_path(base.as_ref());
383
384    // Find the longest common prefix, and also return the path stripped from that prefix
385    let (stripped, common_prefix) = base
386        .ancestors()
387        .find_map(|ancestor| {
388            // Simplifying removes the UNC path prefix on windows.
389            dunce::simplified(&path)
390                .strip_prefix(dunce::simplified(ancestor))
391                .ok()
392                .map(|stripped| (stripped, ancestor))
393        })
394        .ok_or_else(|| {
395            std::io::Error::other(format!(
396                "Trivial strip failed: {} vs. {}",
397                path.simplified_display(),
398                base.simplified_display()
399            ))
400        })?;
401
402    // go as many levels up as required
403    let levels_up = base.components().count() - common_prefix.components().count();
404    let up = std::iter::repeat_n("..", levels_up).collect::<PathBuf>();
405
406    Ok(up.join(stripped))
407}
408
409/// Find the root of the nearest Git repository containing `path`.
410///
411/// A `.git` directory or file is treated as a repository marker to support both regular
412/// repositories and linked worktrees.
413pub fn find_git_repository_root(path: &Path) -> Option<&Path> {
414    // TODO: Consider supporting GIT_CEILING_DIRECTORIES here.
415    path.ancestors()
416        .find(|ancestor| ancestor.join(".git").exists())
417}
418
419/// Try to compute a path relative to `base` if `should_relativize` is true, otherwise return
420/// the absolute path. Falls back to absolute if relativization fails.
421pub fn try_relative_to_if(
422    path: impl AsRef<Path>,
423    base: impl AsRef<Path>,
424    should_relativize: bool,
425) -> Result<PathBuf, std::io::Error> {
426    if should_relativize {
427        relative_to(&path, &base).or_else(|_| std::path::absolute(path.as_ref()))
428    } else {
429        std::path::absolute(path.as_ref())
430    }
431}
432
433/// Convert a [`Path`] to a Windows `verbatim` path (prefixed with `\\?\`) when possible to bypass
434/// Win32 path normalization such as [`MAX_PATH`] and removed trailing characters (dot, space).
435/// Other characters as defined by [`Path.GetInvalidFileNameChars`] are still prohibited. This
436/// function will attempt to perform path normalization similar to Win32 default normalization
437/// without triggering the existing Win32 limitations.
438///
439/// Only [`Prefix::UNC`] and [`Prefix::Disk`] conversion compatible components are supported.
440///   * [`Prefix::UNC`] `\\server\share` becomes `\\?\UNC\server\share`
441///   * [`Prefix::Disk`] `DriveLetter:` becomes `\\?\DriveLetter:`
442///
443/// Other representations do not yield a `verbatim` path. The following cases are returned as-is:
444///   * Non-Windows systems.
445///   * Device paths such as those starting with `\\.\`.
446///   * Paths already prefixed with `\\?\` or `\\?\UNC\`.
447///
448/// WARNING: Adding the `\\?\` prefix effectively skips Win32 default path normalization. Even
449/// though it allows operations on paths that are normally unavailable, it can also be used to
450/// create entries that can potentially lead to further issues with operations that expect
451/// normalization such as symbolic links, junctions or reparse points.
452///
453/// [`MAX_PATH`]: https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation
454/// [`Path.GetInvalidFileNameChars`]: https://learn.microsoft.com/en-us/dotnet/api/system.io.path.getinvalidfilenamechars
455///
456/// See:
457///   * <https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file>
458///   * <https://learn.microsoft.com/en-us/dotnet/standard/io/file-path-formats>
459pub fn verbatim_path(path: &Path) -> Cow<'_, Path> {
460    if !cfg!(windows) {
461        return Cow::Borrowed(path);
462    }
463
464    // Attempt to resolve a fully qualified path just like Win32 path normalization would.
465    // std::path::absolute calls GetFullPathNameW which defeats the purpose of this function
466    // as it results in Win32 default path normalization.
467    let resolved_path = if path.is_relative() {
468        Cow::Owned(CWD.join(path))
469    } else {
470        Cow::Borrowed(path)
471    };
472
473    // Fast Path: we only support verbatim conversion for Prefix::UNC and Prefix::Disk
474    if let Some(Component::Prefix(prefix)) = resolved_path.components().next() {
475        match prefix.kind() {
476            Prefix::UNC(..) | Prefix::Disk(_) => {},
477            // return as-is as there's no verbatim equivalent for `\\.\device`
478            Prefix::DeviceNS(_)
479            // return as-is as its already verbatim
480            | Prefix::Verbatim(_)
481            | Prefix::VerbatimDisk(_)
482            | Prefix::VerbatimUNC(..) => return Cow::Borrowed(path)
483        }
484    }
485
486    // Resolve relative directory components while avoiding default Win32 path normalization
487    let normalized_path = normalized(&resolved_path);
488
489    let mut components = normalized_path.components();
490    let Some(Component::Prefix(prefix)) = components.next() else {
491        return Cow::Borrowed(path);
492    };
493
494    match prefix.kind() {
495        // `DriveLetter:` -> `\\?\DriveLetter:`
496        Prefix::Disk(_) => {
497            let mut result = OsString::from(r"\\?\");
498            result.push(normalized_path.as_os_str()); // e.g. "C:"
499            Cow::Owned(PathBuf::from(result))
500        }
501        // `\\server\share` -> `\\?\UNC\server\share`
502        Prefix::UNC(server, share) => {
503            let mut result = OsString::from(r"\\?\UNC\");
504            result.push(server);
505            result.push(r"\");
506            result.push(share);
507            for component in components {
508                match component {
509                    Component::RootDir => {} // being cautious
510                    Component::Prefix(_) => {
511                        debug_assert!(false, "prefix already consumed");
512                    }
513                    Component::CurDir | Component::ParentDir => {
514                        debug_assert!(false, "path already normalized");
515                    }
516                    Component::Normal(_) => {
517                        result.push(r"\");
518                        result.push(component.as_os_str());
519                    }
520                }
521            }
522            Cow::Owned(PathBuf::from(result))
523        }
524        Prefix::DeviceNS(_)
525        | Prefix::Verbatim(_)
526        | Prefix::VerbatimDisk(_)
527        | Prefix::VerbatimUNC(..) => {
528            debug_assert!(false, "skipped via fast path");
529            Cow::Borrowed(path)
530        }
531    }
532}
533
534/// A path that can be serialized and deserialized in a portable way by converting Windows-style
535/// backslashes to forward slashes, and using a `.` for an empty path.
536///
537/// This implementation assumes that the path is valid UTF-8; otherwise, it won't roundtrip.
538#[derive(Debug, Clone, PartialEq, Eq)]
539pub struct PortablePath<'a>(&'a Path);
540
541#[derive(Debug, Clone, PartialEq, Eq)]
542pub struct PortablePathBuf(Box<Path>);
543
544#[cfg(feature = "schemars")]
545impl schemars::JsonSchema for PortablePathBuf {
546    fn schema_name() -> Cow<'static, str> {
547        Cow::Borrowed("PortablePathBuf")
548    }
549
550    fn json_schema(_gen: &mut schemars::generate::SchemaGenerator) -> schemars::Schema {
551        PathBuf::json_schema(_gen)
552    }
553}
554
555impl AsRef<Path> for PortablePath<'_> {
556    fn as_ref(&self) -> &Path {
557        self.0
558    }
559}
560
561impl<'a, T> From<&'a T> for PortablePath<'a>
562where
563    T: AsRef<Path> + ?Sized,
564{
565    fn from(path: &'a T) -> Self {
566        PortablePath(path.as_ref())
567    }
568}
569
570impl std::fmt::Display for PortablePath<'_> {
571    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
572        let path = self.0.to_slash_lossy();
573        if path.is_empty() {
574            write!(f, ".")
575        } else {
576            write!(f, "{path}")
577        }
578    }
579}
580
581impl std::fmt::Display for PortablePathBuf {
582    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
583        let path = self.0.to_slash_lossy();
584        if path.is_empty() {
585            write!(f, ".")
586        } else {
587            write!(f, "{path}")
588        }
589    }
590}
591
592impl From<&str> for PortablePathBuf {
593    fn from(path: &str) -> Self {
594        if path == "." {
595            Self(PathBuf::new().into_boxed_path())
596        } else {
597            Self(PathBuf::from(path).into_boxed_path())
598        }
599    }
600}
601
602impl From<PortablePathBuf> for Box<Path> {
603    fn from(portable: PortablePathBuf) -> Self {
604        portable.0
605    }
606}
607
608impl From<Box<Path>> for PortablePathBuf {
609    fn from(path: Box<Path>) -> Self {
610        Self(path)
611    }
612}
613
614impl<'a> From<&'a Path> for PortablePathBuf {
615    fn from(path: &'a Path) -> Self {
616        Box::<Path>::from(path).into()
617    }
618}
619
620#[cfg(feature = "serde")]
621impl serde::Serialize for PortablePathBuf {
622    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
623    where
624        S: serde::ser::Serializer,
625    {
626        self.to_string().serialize(serializer)
627    }
628}
629
630#[cfg(feature = "serde")]
631impl serde::Serialize for PortablePath<'_> {
632    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
633    where
634        S: serde::ser::Serializer,
635    {
636        self.to_string().serialize(serializer)
637    }
638}
639
640#[cfg(feature = "serde")]
641impl<'de> serde::de::Deserialize<'de> for PortablePathBuf {
642    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
643    where
644        D: serde::de::Deserializer<'de>,
645    {
646        let s = <Cow<'_, str>>::deserialize(deserializer)?;
647        if s == "." {
648            Ok(Self(PathBuf::new().into_boxed_path()))
649        } else {
650            Ok(Self(PathBuf::from(s.as_ref()).into_boxed_path()))
651        }
652    }
653}
654
655impl AsRef<Path> for PortablePathBuf {
656    fn as_ref(&self) -> &Path {
657        &self.0
658    }
659}
660
661#[cfg(test)]
662mod tests {
663    use std::assert_matches;
664
665    use super::*;
666
667    #[test]
668    fn test_find_git_repository_root() -> std::io::Result<()> {
669        let temp_dir = tempfile::tempdir()?;
670
671        let repository = temp_dir.path().join("repository");
672        let nested = repository.join("packages/project");
673        fs_err::create_dir_all(repository.join(".git"))?;
674        fs_err::create_dir_all(&nested)?;
675        assert_eq!(
676            find_git_repository_root(&nested),
677            Some(repository.as_path())
678        );
679
680        let worktree = temp_dir.path().join("worktree");
681        let nested = worktree.join("packages/project");
682        fs_err::create_dir_all(&nested)?;
683        fs_err::write(worktree.join(".git"), "gitdir: ../repository/.git")?;
684        assert_eq!(find_git_repository_root(&nested), Some(worktree.as_path()));
685
686        Ok(())
687    }
688
689    #[test]
690    fn test_normalize_url() {
691        if cfg!(windows) {
692            assert_eq!(
693                normalize_url_path("/C:/Users/ferris/wheel-0.42.0.tar.gz"),
694                "C:\\Users\\ferris\\wheel-0.42.0.tar.gz"
695            );
696        } else {
697            assert_eq!(
698                normalize_url_path("/C:/Users/ferris/wheel-0.42.0.tar.gz"),
699                "/C:/Users/ferris/wheel-0.42.0.tar.gz"
700            );
701        }
702
703        if cfg!(windows) {
704            assert_eq!(
705                normalize_url_path("./ferris/wheel-0.42.0.tar.gz"),
706                ".\\ferris\\wheel-0.42.0.tar.gz"
707            );
708        } else {
709            assert_eq!(
710                normalize_url_path("./ferris/wheel-0.42.0.tar.gz"),
711                "./ferris/wheel-0.42.0.tar.gz"
712            );
713        }
714
715        if cfg!(windows) {
716            assert_eq!(
717                normalize_url_path("./wheel%20cache/wheel-0.42.0.tar.gz"),
718                ".\\wheel cache\\wheel-0.42.0.tar.gz"
719            );
720        } else {
721            assert_eq!(
722                normalize_url_path("./wheel%20cache/wheel-0.42.0.tar.gz"),
723                "./wheel cache/wheel-0.42.0.tar.gz"
724            );
725        }
726    }
727
728    #[test]
729    fn test_normalize_path() {
730        let path = Path::new("/a/b/../c/./d");
731        let normalized = normalize_absolute_path(path).unwrap();
732        assert_eq!(normalized, Path::new("/a/c/d"));
733
734        let path = Path::new("/a/../c/./d");
735        let normalized = normalize_absolute_path(path).unwrap();
736        assert_eq!(normalized, Path::new("/c/d"));
737
738        // This should be an error.
739        let path = Path::new("/a/../../c/./d");
740        let err = normalize_absolute_path(path).unwrap_err();
741        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
742    }
743
744    #[test]
745    fn test_relative_to() {
746        assert_eq!(
747            relative_to(
748                Path::new("/home/ferris/carcinization/lib/python/site-packages/foo/__init__.py"),
749                Path::new("/home/ferris/carcinization/lib/python/site-packages"),
750            )
751            .unwrap(),
752            Path::new("foo/__init__.py")
753        );
754        assert_eq!(
755            relative_to(
756                Path::new("/home/ferris/carcinization/lib/marker.txt"),
757                Path::new("/home/ferris/carcinization/lib/python/site-packages"),
758            )
759            .unwrap(),
760            Path::new("../../marker.txt")
761        );
762        assert_eq!(
763            relative_to(
764                Path::new("/home/ferris/carcinization/bin/foo_launcher"),
765                Path::new("/home/ferris/carcinization/lib/python/site-packages"),
766            )
767            .unwrap(),
768            Path::new("../../../bin/foo_launcher")
769        );
770    }
771
772    #[test]
773    fn test_normalize_relative() {
774        let cases = [
775            (
776                "../../workspace-git-path-dep-test/packages/c/../../packages/d",
777                "../../workspace-git-path-dep-test/packages/d",
778            ),
779            (
780                "workspace-git-path-dep-test/packages/c/../../packages/d",
781                "workspace-git-path-dep-test/packages/d",
782            ),
783            ("./a/../../b", "../b"),
784            ("/usr/../../foo", "/../foo"),
785            // Interior `.` segments (stripped by `Path::components`).
786            ("foo/./bar", "foo/bar"),
787            ("/a/./b/./c", "/a/b/c"),
788            ("./foo/bar", "foo/bar"),
789            (".", ""),
790            ("./.", ""),
791            ("foo/.", "foo"),
792            // Repeated separators (also stripped by `Path::components`).
793            ("foo//bar", "foo/bar"),
794            ("/a///b//c", "/a/b/c"),
795            // Mixed `.` and `..`.
796            ("foo/./../bar", "bar"),
797            ("foo/bar/./../baz", "foo/baz"),
798            // Already-normalized paths.
799            ("foo/bar", "foo/bar"),
800            ("/a/b/c", "/a/b/c"),
801            ("", ""),
802        ];
803        for (input, expected) in cases {
804            assert_eq!(
805                normalize_path(Path::new(input)),
806                Path::new(expected),
807                "input: {input:?}"
808            );
809        }
810
811        // Verify the fast path: already-normalized inputs are returned borrowed.
812        for already_normalized in ["foo/bar", "/a/b/c", "foo", "/", ""] {
813            let path = Path::new(already_normalized);
814            assert_matches!(
815                normalize_path(path),
816                Cow::Borrowed(_),
817                "expected borrowed for {already_normalized:?}"
818            );
819        }
820    }
821
822    #[test]
823    fn test_normalize_path_under() {
824        assert_eq!(
825            normalize_path_under("scripts/script", "scripts"),
826            Some(PathBuf::from("scripts/script"))
827        );
828        assert_eq!(
829            normalize_path_under("/scripts/script", "/scripts"),
830            Some(PathBuf::from("/scripts/script"))
831        );
832        assert_eq!(
833            normalize_path_under("scripts/nested/../script", "scripts"),
834            Some(PathBuf::from("scripts/script"))
835        );
836        assert_eq!(
837            normalize_path_under("/scripts/nested/../script", "/scripts"),
838            Some(PathBuf::from("/scripts/script"))
839        );
840        assert_eq!(normalize_path_under("scripts/.", "scripts"), None);
841        assert_eq!(normalize_path_under("/scripts/.", "/scripts"), None);
842        assert_eq!(normalize_path_under("scripts/../script", "scripts"), None);
843        assert_eq!(normalize_path_under("/scripts/../script", "/scripts"), None);
844        assert_eq!(normalize_path_under("scripts/script", "."), None);
845        assert_eq!(normalize_path_under("scripts/script", ""), None);
846    }
847
848    #[test]
849    fn test_normalize_trailing_path_separator() {
850        let cases = [
851            (
852                "/home/ferris/projects/python/",
853                "/home/ferris/projects/python",
854            ),
855            ("python/", "python"),
856            ("/", "/"),
857            ("foo/bar/", "foo/bar"),
858            ("foo//", "foo"),
859        ];
860        for (input, expected) in cases {
861            assert_eq!(normalize_path(Path::new(input)), Path::new(expected));
862        }
863    }
864
865    #[test]
866    #[cfg(windows)]
867    fn test_normalize_windows() {
868        let cases = [
869            (
870                r"C:\Users\Ferris\projects\python\",
871                r"C:\Users\Ferris\projects\python",
872            ),
873            (r"C:\foo\.\bar", r"C:\foo\bar"),
874            (r"C:\foo\\bar", r"C:\foo\bar"),
875            (r"C:\foo\bar\..\baz", r"C:\foo\baz"),
876            (r"foo\.\bar", r"foo\bar"),
877            (r"C:foo", r"C:foo"),
878            (r"C:\foo", r"C:\foo"),
879            (r"C:\\foo", r"C:\foo"),
880            (r"\\?\C:foo", r"\\?\C:foo"),
881            (r"\\?\C:\foo", r"\\?\C:\foo"),
882            (r"\\?\C:\\foo", r"\\?\C:\foo"),
883            (r"\\server\share\foo", r"\\server\share\foo"),
884        ];
885        for (input, expected) in cases {
886            assert_eq!(normalize_path(Path::new(input)), Path::new(expected));
887        }
888    }
889
890    #[cfg(windows)]
891    #[test]
892    fn test_verbatim_path() {
893        let relative_path = format!(r"\\?\{}\path\to\logging.", CWD.simplified_display());
894        let relative_root = format!(
895            r"\\?\{}\path\to\logging.",
896            CWD.components()
897                .next()
898                .expect("expected a drive letter prefix")
899                .simplified_display()
900        );
901        let cases = [
902            // Non-Verbatim disk
903            (r"C:\path\to\logging.", r"\\?\C:\path\to\logging."),
904            (r"C:\path\to\.\logging.", r"\\?\C:\path\to\logging."),
905            (r"C:\path\to\..\to\logging.", r"\\?\C:\path\to\logging."),
906            (r"C:/path/to/../to/./logging.", r"\\?\C:\path\to\logging."),
907            (r"C:path\to\..\to\logging.", r"\\?\C:path\to\logging."), // @TODO(samypr100) we do not support expanding drive-relative paths
908            (r".\path\to\.\logging.", relative_path.as_str()),
909            (r"path\to\..\to\logging.", relative_path.as_str()),
910            (r"./path/to/logging.", relative_path.as_str()),
911            (r"\path\to\logging.", relative_root.as_str()),
912            // Non-Verbatim UNC
913            (
914                r"\\127.0.0.1\c$\path\to\logging.",
915                r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
916            ),
917            (
918                r"\\127.0.0.1\c$\path\to\.\logging.",
919                r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
920            ),
921            (
922                r"\\127.0.0.1\c$\path\to\..\to\logging.",
923                r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
924            ),
925            (
926                r"//127.0.0.1/c$/path/to/../to/./logging.",
927                r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
928            ),
929            // Verbatim Disk
930            (r"\\?\C:\path\to\logging.", r"\\?\C:\path\to\logging."),
931            // Verbatim UNC
932            (
933                r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
934                r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
935            ),
936            // Device Namespace
937            (r"\\.\PhysicalDrive0", r"\\.\PhysicalDrive0"),
938            (r"\\.\NUL", r"\\.\NUL"),
939        ];
940
941        for (input, expected) in cases {
942            assert_eq!(verbatim_path(Path::new(input)), Path::new(expected));
943        }
944    }
945
946    #[test]
947    #[cfg(unix)]
948    fn test_path_escape_for_python() {
949        use std::ffi::OsStr;
950        use std::os::unix::ffi::OsStrExt;
951
952        // A *nix path can contain any byte except NUL.
953        // Each expected value is intentionally Python pastable for verification.
954        for (range, expected) in [
955            (
956                1..=0x5e,
957                r##"__import__("os").fsdecode(b"/foo/\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0c\r\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f !\"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^/bar")"##,
958            ),
959            (
960                0x5f..=0xa3,
961                r#"__import__("os").fsdecode(b"/foo/_`abcdefghijklmnopqrstuvwxyz{|}~\x7f\x80\x81\x82\x83\x84\x85\x86\x87\x88\x89\x8a\x8b\x8c\x8d\x8e\x8f\x90\x91\x92\x93\x94\x95\x96\x97\x98\x99\x9a\x9b\x9c\x9d\x9e\x9f\xa0\xa1\xa2\xa3/bar")"#,
962            ),
963            (
964                0xa4..=0xd1,
965                r#"__import__("os").fsdecode(b"/foo/\xa4\xa5\xa6\xa7\xa8\xa9\xaa\xab\xac\xad\xae\xaf\xb0\xb1\xb2\xb3\xb4\xb5\xb6\xb7\xb8\xb9\xba\xbb\xbc\xbd\xbe\xbf\xc0\xc1\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\xca\xcb\xcc\xcd\xce\xcf\xd0\xd1/bar")"#,
966            ),
967            (
968                0xd2..=u8::MAX,
969                r#"__import__("os").fsdecode(b"/foo/\xd2\xd3\xd4\xd5\xd6\xd7\xd8\xd9\xda\xdb\xdc\xdd\xde\xdf\xe0\xe1\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea\xeb\xec\xed\xee\xef\xf0\xf1\xf2\xf3\xf4\xf5\xf6\xf7\xf8\xf9\xfa\xfb\xfc\xfd\xfe\xff/bar")"#,
970            ),
971        ] {
972            let bytes = b"/foo/"
973                .iter()
974                .copied()
975                .chain(range)
976                .chain(b"/bar".iter().copied())
977                .collect::<Box<[_]>>();
978            let path = Path::new(OsStr::from_bytes(&bytes));
979            assert_eq!(path.escape_for_python(), expected);
980        }
981    }
982
983    #[cfg(windows)]
984    #[test]
985    fn test_path_escape_for_python() {
986        use std::os::windows::ffi::OsStringExt;
987
988        // Exhaustive testing for windows is impractical, this has ASCII range (including NUL and
989        // control chars), surrogate pairs, and unpaired surrogates.
990        let path_osstr = OsString::from_wide(&[
991            0x5c, 0x5c, 0x3f, 0x5c, 0x43, 0x3a, 0x5c, 0x63, 0x61, 0x66, 0x00e9, 0x5c, 0xd83d,
992            0xde00, 0x5c, 0xd800, 0x78, 0xdc00, 0x22, 0x09, 0x0a, 0x0d,
993        ]);
994        let path = Path::new(&path_osstr);
995        // This should be copy-pastable into a python interpreter and reproduce the path.
996        assert_eq!(
997            path.escape_for_python(),
998            r#""\\\\?\\C:\\café\\😀\\\ud800x\udc00\"\t\n\r""#
999        );
1000    }
1001}