Skip to main content

datui_lib/
canonical.rs

1//! `canonicalize` without Windows' `\\?\` prefix.
2//!
3//! On Windows `std::fs::canonicalize` answers `\\?\C:\data\a.csv`: a verbatim path, which
4//! nothing else in datui spells that way. Recorded as a recent it read `\\?\C:\…` rather
5//! than `~\…`, and it matched no row a listing built from `C:\data`. The prefix is dropped
6//! wherever the plain spelling names the same file, which is what the `dunce` crate does.
7//! Everything that canonicalizes a path goes through here, tests included, so what is
8//! stored and what it is compared with are spelled alike.
9
10use std::path::{Path, PathBuf};
11
12/// [`Path::canonicalize`], in the spelling a Windows user would type.
13pub fn canonicalize(path: &Path) -> std::io::Result<PathBuf> {
14    let canonical = path.canonicalize()?;
15    if !cfg!(windows) {
16        return Ok(canonical);
17    }
18    Ok(match canonical.to_str().and_then(simplified) {
19        Some(plain) => PathBuf::from(plain),
20        None => canonical,
21    })
22}
23
24/// The plain spelling of a verbatim path, when one means the same thing.
25///
26/// `None` otherwise: a path past `MAX_PATH` is reachable only verbatim, and a verbatim
27/// path can hold names the plain form would read differently — `a.` and `b ` lose their
28/// ends, and `CON` or `nul.txt` is a device wherever it appears.
29fn simplified(path: &str) -> Option<String> {
30    let (plain, names) = if let Some(rest) = path.strip_prefix(r"\\?\UNC\") {
31        (format!(r"\\{rest}"), rest)
32    } else {
33        let rest = path.strip_prefix(r"\\?\")?;
34        let bytes = rest.as_bytes();
35        let drive = bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && &bytes[1..3] == b":\\";
36        if !drive {
37            return None;
38        }
39        (rest.to_string(), &rest[3..])
40    };
41    const MAX_PATH: usize = 260;
42    if plain.len() >= MAX_PATH {
43        return None;
44    }
45    names
46        .split('\\')
47        .filter(|name| !name.is_empty())
48        .all(plain_name_means_the_same)
49        .then_some(plain)
50}
51
52fn plain_name_means_the_same(name: &str) -> bool {
53    const DEVICES: [&str; 22] = [
54        "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
55        "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
56    ];
57    let stem = name.split('.').next().unwrap_or(name).trim_end();
58    !name.ends_with(['.', ' '])
59        && !name.contains('/')
60        && !DEVICES.iter().any(|d| d.eq_ignore_ascii_case(stem))
61}
62
63#[cfg(test)]
64mod tests {
65    use super::simplified;
66
67    #[test]
68    fn a_drive_path_loses_its_prefix() {
69        assert_eq!(
70            simplified(r"\\?\C:\Users\me\data\a.csv").as_deref(),
71            Some(r"C:\Users\me\data\a.csv")
72        );
73        assert_eq!(simplified(r"\\?\D:\").as_deref(), Some(r"D:\"));
74    }
75
76    #[test]
77    fn a_share_becomes_a_unc_path() {
78        assert_eq!(
79            simplified(r"\\?\UNC\nas\data\sales.parquet").as_deref(),
80            Some(r"\\nas\data\sales.parquet")
81        );
82    }
83
84    #[test]
85    fn a_path_only_the_verbatim_form_reaches_keeps_it() {
86        for kept in [
87            r"\\?\C:\data\trailing.",
88            r"\\?\C:\data\trailing ",
89            r"\\?\C:\data\CON",
90            r"\\?\C:\data\nul.txt",
91            r"\\?\C:\data\a/b",
92            r"\\?\Volume{0b1c}\data",
93            r"C:\already\plain",
94        ] {
95            assert_eq!(simplified(kept), None, "{kept}");
96        }
97        let long = format!(r"\\?\C:\{}", "a".repeat(300));
98        assert_eq!(simplified(&long), None);
99    }
100
101    #[test]
102    fn a_name_that_only_starts_like_a_device_is_plain() {
103        assert_eq!(
104            simplified(r"\\?\C:\data\console.csv").as_deref(),
105            Some(r"C:\data\console.csv")
106        );
107    }
108}