Skip to main content

path_rs/
platform.rs

1//! Platform-specific path classification and Windows helpers.
2//!
3//! These helpers inspect path *syntax*. They do not access the filesystem.
4
5use crate::error::PathError;
6use crate::internal::components::{is_drive_relative_path, reserved_base_name};
7use std::ffi::OsStr;
8use std::path::{Component, Path, PathBuf, Prefix};
9
10/// Returns `true` if `path` is a Windows drive-relative path such as `C:foo` or `C:`.
11///
12/// Drive-relative paths are not portable and are rejected by resolution helpers by default.
13///
14/// # Filesystem access
15///
16/// No.
17pub fn is_drive_relative(path: &Path) -> bool {
18    is_drive_relative_path(path)
19}
20
21/// Returns `true` if `path` uses a UNC prefix (`\\server\share` or verbatim UNC).
22///
23/// # Filesystem access
24///
25/// No.
26pub fn is_unc(path: &Path) -> bool {
27    match path.components().next() {
28        Some(Component::Prefix(prefix)) => {
29            matches!(prefix.kind(), Prefix::UNC(_, _) | Prefix::VerbatimUNC(_, _))
30        }
31        _ => false,
32    }
33}
34
35/// Returns `true` if `path` uses a Windows verbatim prefix (`\\?\...`).
36///
37/// # Filesystem access
38///
39/// No.
40pub fn is_verbatim(path: &Path) -> bool {
41    match path.components().next() {
42        Some(Component::Prefix(prefix)) => matches!(
43            prefix.kind(),
44            Prefix::Verbatim(_) | Prefix::VerbatimUNC(_, _) | Prefix::VerbatimDisk(_)
45        ),
46        _ => false,
47    }
48}
49
50/// Returns `true` if `path` uses a device namespace prefix (`\\.\...`).
51///
52/// # Filesystem access
53///
54/// No.
55pub fn is_device_namespace(path: &Path) -> bool {
56    match path.components().next() {
57        Some(Component::Prefix(prefix)) => matches!(prefix.kind(), Prefix::DeviceNS(_)),
58        _ => false,
59    }
60}
61
62/// Returns `true` if `name` is a Windows reserved device name (optionally with extension).
63///
64/// Examples: `CON`, `NUL.txt`, `COM1.log`.
65///
66/// # Filesystem access
67///
68/// No.
69pub fn is_reserved_windows_name(name: &OsStr) -> bool {
70    reserved_base_name(name).is_some()
71}
72
73/// Returns `true` if any normal component of `path` is a Windows reserved name.
74///
75/// # Filesystem access
76///
77/// No.
78pub fn path_contains_reserved_name(path: &Path) -> bool {
79    path.components().any(|c| match c {
80        Component::Normal(name) => is_reserved_windows_name(name),
81        _ => false,
82    })
83}
84
85/// Translate a WSL-style `/mnt/<drive>/...` path to a Windows path.
86///
87/// Only single-letter drive mounts are recognized (`/mnt/c/...`).
88/// Returns `Ok(None)` when the input is not a WSL mount path.
89///
90/// Translation is only performed when `cfg!(windows)` is true; on other platforms
91/// the function still parses and, if requested via callers, may return a synthetic
92/// Windows-style path string for tooling that needs the mapping.
93///
94/// # Filesystem access
95///
96/// No.
97pub fn translate_wsl_path(input: &str) -> Result<Option<PathBuf>, PathError> {
98    let trimmed = input.trim();
99    if !trimmed.starts_with("/mnt/") {
100        return Ok(None);
101    }
102
103    let rest = &trimmed[5..]; // after "/mnt/"
104    if rest.is_empty() {
105        return Err(PathError::invalid(
106            "WSL path '/mnt/' is incomplete; expected /mnt/<drive>/...",
107        ));
108    }
109
110    let mut parts = rest.splitn(2, '/');
111    let drive = parts.next().unwrap_or("");
112    let tail = parts.next().unwrap_or("");
113
114    if drive.is_empty() {
115        return Err(PathError::invalid("WSL path is missing a drive letter"));
116    }
117
118    // Exactly one ASCII alphabetic character.
119    let mut chars = drive.chars();
120    let Some(letter) = chars.next() else {
121        return Err(PathError::invalid("WSL path is missing a drive letter"));
122    };
123    if chars.next().is_some() || !letter.is_ascii_alphabetic() {
124        // Not a single-letter drive mount (e.g. /mnt/data, /mnt/cc, /mnt/1).
125        return Ok(None);
126    }
127
128    // Bare `/mnt/c` without trailing path: treat as incomplete for safety when no more path.
129    // Callers often accept `/mnt/c` as the drive root; we allow it as `<letter>:\`.
130    let mut out = PathBuf::new();
131    let mut drive_root = String::with_capacity(3);
132    drive_root.push(letter.to_ascii_uppercase());
133    drive_root.push(':');
134    drive_root.push('\\');
135    out.push(drive_root);
136
137    if !tail.is_empty() {
138        for segment in tail.split('/') {
139            if segment.is_empty() || segment == "." {
140                continue;
141            }
142            if segment == ".." {
143                if !out.pop() {
144                    return Err(PathError::invalid(
145                        "WSL path escapes the drive root via '..'",
146                    ));
147                }
148                // Ensure we still have a drive root.
149                if out.as_os_str().is_empty() {
150                    return Err(PathError::invalid(
151                        "WSL path escapes the drive root via '..'",
152                    ));
153                }
154                continue;
155            }
156            out.push(segment);
157        }
158    }
159
160    Ok(Some(out))
161}
162
163/// Soften a Windows path for display using `dunce` (may strip `\\?\` when safe).
164///
165/// Does not mutate paths used for security decisions; intended for user-facing output.
166///
167/// # Filesystem access
168///
169/// No.
170pub fn simplify_for_display(path: &Path) -> PathBuf {
171    dunce::simplified(path).to_path_buf()
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use std::path::Path;
178
179    #[test]
180    fn reserved_names() {
181        assert!(is_reserved_windows_name(OsStr::new("CON")));
182        assert!(is_reserved_windows_name(OsStr::new("nul.txt")));
183        assert!(is_reserved_windows_name(OsStr::new("COM1.log")));
184        assert!(!is_reserved_windows_name(OsStr::new("console")));
185        assert!(!is_reserved_windows_name(OsStr::new("file.txt")));
186    }
187
188    #[test]
189    fn wsl_translation() {
190        let p = translate_wsl_path("/mnt/c/Users/floris").unwrap().unwrap();
191        assert_eq!(p, PathBuf::from(r"C:\Users\floris"));
192
193        let p = translate_wsl_path("/mnt/d/repo").unwrap().unwrap();
194        assert_eq!(p, PathBuf::from(r"D:\repo"));
195
196        assert!(translate_wsl_path("/mnt/data/foo").unwrap().is_none());
197        assert!(translate_wsl_path("/mnt/cc/foo").unwrap().is_none());
198        assert!(translate_wsl_path("/mnt/1/foo").unwrap().is_none());
199        assert!(translate_wsl_path("/mnt/~/foo").unwrap().is_none());
200        assert!(translate_wsl_path("/home/user").unwrap().is_none());
201        assert!(translate_wsl_path("/mnt/").is_err());
202    }
203
204    #[cfg(windows)]
205    #[test]
206    fn windows_prefix_detection() {
207        assert!(is_drive_relative(Path::new(r"C:foo")));
208        assert!(is_drive_relative(Path::new(r"C:")));
209        assert!(!is_drive_relative(Path::new(r"C:\foo")));
210        assert!(!is_drive_relative(Path::new(r"C:/foo")));
211        assert!(is_unc(Path::new(r"\\server\share")));
212        assert!(is_verbatim(Path::new(r"\\?\C:\repo")));
213        assert!(is_device_namespace(Path::new(r"\\.\PIPE\name")));
214    }
215}