codehelion_core/paths.rs
1//! Resolving a path to the single spelling everything else compares against.
2
3use std::path::{Path, PathBuf};
4
5/// What the ordinary Windows rules cap a path at. A longer one is reachable
6/// only through the verbatim form, which is the case that form exists for.
7#[cfg(any(windows, test))]
8const PATH_LIMIT: usize = 260;
9
10/// Resolve `path` to its canonical location, spelled the way the platform
11/// ordinarily spells it.
12///
13/// This is [`std::fs::canonicalize`] everywhere except in what it does with
14/// Windows' answer. There, canonicalizing returns a *verbatim* path — the
15/// `\\?\` form, which exists so that names the ordinary rules cannot express
16/// are still reachable. Two things follow from keeping that form. It is what
17/// a person is shown, in place of the path they typed. And it is what gets
18/// recorded as the identity of a scanned tree, so a later invocation that
19/// arrives spelled ordinarily names a different tree and finds nothing of
20/// what was recorded.
21///
22/// The prefix is therefore dropped whenever the remainder still names the
23/// same file, and kept whenever it does not — because for those paths the
24/// verbatim form is not decoration, it is the only spelling that works.
25///
26/// # Errors
27///
28/// Returns whatever [`std::fs::canonicalize`] returns: the path has to exist
29/// and every component of it has to be traversable.
30pub fn canonical(path: &Path) -> std::io::Result<PathBuf> {
31 let resolved = path.canonicalize()?;
32 #[cfg(windows)]
33 {
34 // A path that is not UTF-8 keeps the form it was given: reading it
35 // apart below would mean deciding what its bytes say.
36 let simplified = resolved.to_str().and_then(simplify).map(PathBuf::from);
37 Ok(simplified.unwrap_or(resolved))
38 }
39 #[cfg(not(windows))]
40 {
41 Ok(resolved)
42 }
43}
44
45// Compiled where it is used and where it is checked. The rule is about
46// Windows paths, and the tests that hold it to account are run everywhere —
47// which is the only reason a mistake in it is found by anything other than a
48// Windows machine.
49/// Rewrite a Windows verbatim path in the ordinary form, or decline.
50///
51/// Read as text rather than through [`Path`], because on every platform but
52/// one `Path` does not know what these strings are — and a rule that can only
53/// be exercised where it is used is a rule nobody is checking.
54///
55/// Declining is the safe answer, and is taken whenever anything about the
56/// path makes the two forms name different things.
57#[cfg(any(windows, test))]
58fn simplify(path: &str) -> Option<&str> {
59 let simplified = path.strip_prefix(r"\\?\")?;
60 if simplified.len() >= PATH_LIMIT {
61 return None;
62 }
63 // A local drive, and nothing else. A share (`UNC\server\...`) would be a
64 // different rewrite, and a device (`PIPE\name`) has no other spelling at
65 // all.
66 let (drive, rest) = simplified.split_at_checked(3)?;
67 let mut spelling = drive.chars();
68 if !spelling
69 .next()
70 .is_some_and(|letter| letter.is_ascii_alphabetic())
71 || spelling.next() != Some(':')
72 || spelling.next() != Some('\\')
73 {
74 return None;
75 }
76 // The drive's own root has no components to check and is reached the same
77 // way under either form.
78 if rest.is_empty() {
79 return Some(simplified);
80 }
81 rest.split('\\')
82 .all(ordinarily_reachable)
83 .then_some(simplified)
84}
85
86/// Whether a path component means the same thing outside the verbatim form.
87///
88/// Four kinds do not. A name the system reserves for a device is resolved to
89/// that device rather than to the file. A name ending in a dot or a space has
90/// those characters stripped. A `.` or `..` is resolved rather than taken
91/// literally, which is the whole difference the verbatim form makes. And an
92/// empty component is a repeated separator, which the ordinary rules collapse
93/// and the verbatim form keeps.
94#[cfg(any(windows, test))]
95fn ordinarily_reachable(component: &str) -> bool {
96 const RESERVED: [&str; 4] = ["CON", "PRN", "AUX", "NUL"];
97 const NUMBERED: [&str; 2] = ["COM", "LPT"];
98
99 if matches!(component, "" | "." | "..") || component.ends_with('.') || component.ends_with(' ')
100 {
101 return false;
102 }
103 let stem = component.split('.').next().unwrap_or(component).trim_end();
104 if RESERVED
105 .iter()
106 .any(|reserved| stem.eq_ignore_ascii_case(reserved))
107 {
108 return false;
109 }
110 // `COM1` through `COM9` and the same for `LPT`. `COM10` is a file.
111 !NUMBERED.iter().any(|device| {
112 stem.len() == device.len() + 1
113 && stem
114 .get(..device.len())
115 .is_some_and(|prefix| prefix.eq_ignore_ascii_case(device))
116 && stem
117 .as_bytes()
118 .last()
119 .is_some_and(|digit| digit.is_ascii_digit() && *digit != b'0')
120 })
121}
122
123#[cfg(test)]
124mod tests;