Skip to main content

devset_core/
path.rs

1//! Managed paths, checked to stay inside their root on every supported filesystem.
2
3use camino::{Utf8Path, Utf8PathBuf};
4use derive_more::{AsRef, Display, Into};
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use unicode_normalization::UnicodeNormalization;
8
9use crate::errors::PathError;
10
11/// Components no managed path may contain, compared by [`RelPath::fold`].
12///
13/// Repository internals, their NTFS short name, and devset's own state.
14const RESERVED: [&str; 3] = [".git", "git~1", ".devset"];
15
16/// Characters some supported filesystem cannot store.
17const UNPORTABLE: [char; 8] = ['\\', ':', '*', '?', '"', '<', '>', '|'];
18
19/// Code points HFS+ ignores in names, so `.g\u{200C}it` is `.git` there. Git's list.
20const IGNORABLE: [char; 15] = [
21    '\u{200C}', '\u{200D}', '\u{200E}', '\u{200F}', '\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}',
22    '\u{202E}', '\u{206A}', '\u{206B}', '\u{206C}', '\u{206D}', '\u{206E}', '\u{FEFF}',
23];
24
25/// A path a profile manages, relative to the target root.
26///
27/// `/`-separated; no empty, `.` or `..` components; no component ending in `.` or a space; only
28/// characters every supported filesystem can store; and no component that folds to `.git` or
29/// `.devset`. Anything built from a `RelPath` stays inside its root.
30#[derive(
31    Clone,
32    PartialEq,
33    Eq,
34    PartialOrd,
35    Ord,
36    Hash,
37    AsRef,
38    Display,
39    derive_more::Debug,
40    Into,
41    Serialize,
42    Deserialize,
43    JsonSchema,
44)]
45#[as_ref(str)]
46#[debug("{_0:?}")]
47#[into(String)]
48#[serde(try_from = "String", into = "String")]
49#[schemars(inline)]
50pub struct RelPath(Box<str>);
51
52impl RelPath {
53    /// `path`, checked against the rules above.
54    ///
55    /// # Errors
56    /// [`PathError`], `path` breaks one of them.
57    pub fn new(path: &str) -> Result<Self, PathError> {
58        Self::try_from(String::from(path))
59    }
60
61    /// The path as written.
62    #[must_use]
63    pub fn as_str(&self) -> &str {
64        &self.0
65    }
66
67    /// This path resolved under `root`.
68    #[must_use]
69    pub fn under(&self, root: &Utf8Path) -> Utf8PathBuf {
70        let mut path = root.to_path_buf();
71        path.extend(self.0.split('/'));
72        path
73    }
74
75    /// The last component: the file's name.
76    pub(crate) fn file_name(&self) -> &str {
77        self.0.rsplit_once('/').map_or(&self.0, |(_, name)| name)
78    }
79
80    /// What follows the file name's last `.`, lowercased; `None` without one.
81    pub(crate) fn extension(&self) -> Option<String> {
82        let (_, extension) = self.file_name().rsplit_once('.')?;
83        Some(extension.to_ascii_lowercase())
84    }
85
86    /// The key two paths share when a case- or normalization-insensitive filesystem merges them.
87    #[must_use]
88    pub fn fold(&self) -> String {
89        fold(&self.0)
90    }
91}
92
93/// NFC, lowercase, HFS+ ignorables removed.
94fn fold(s: &str) -> String {
95    s.nfc().flat_map(char::to_lowercase).filter(|c| !IGNORABLE.contains(c)).collect()
96}
97
98/// The first rule `path` breaks.
99fn check(path: &str) -> Result<(), &'static str> {
100    if path.is_empty() {
101        return Err("empty");
102    }
103    if path.starts_with('/') {
104        return Err("absolute");
105    }
106    for part in path.split('/') {
107        if part.is_empty() || part == "." || part == ".." {
108            return Err("empty, `.` or `..` component");
109        }
110        if part.ends_with(['.', ' ']) {
111            return Err("component ends in `.` or a space");
112        }
113        if part.chars().any(|c| c.is_control() || UNPORTABLE.contains(&c)) {
114            return Err("unportable character");
115        }
116        if RESERVED.contains(&fold(part).as_str()) {
117            return Err("reserved component");
118        }
119    }
120    Ok(())
121}
122
123impl TryFrom<String> for RelPath {
124    type Error = PathError;
125
126    fn try_from(path: String) -> Result<Self, PathError> {
127        match check(&path) {
128            Ok(()) => Ok(Self(path.into_boxed_str())),
129            Err(rule) => Err(PathError { path, rule }),
130        }
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::RelPath;
137
138    #[test]
139    fn accepts() {
140        for path in ["a", ".github/workflows/ci.yml", ".gitignore", "a.b/c-d_e", "devset/x"] {
141            assert!(RelPath::new(path).is_ok(), "{path} should be accepted");
142        }
143    }
144
145    #[test]
146    fn rejects() {
147        for path in [
148            "",
149            "/a",
150            "a//b",
151            "a/",
152            "./a",
153            "a/../b",
154            "a.",
155            "a ",
156            "a\\b",
157            "c:x",
158            "a\nb",
159            ".git",
160            "x/.GIT/config",
161            "x/.g\u{200C}it",
162            "GIT~1/x",
163            ".devset/state.toml",
164            "x/.DevSet",
165        ] {
166            assert!(RelPath::new(path).is_err(), "{path:?} should be rejected");
167        }
168    }
169
170    #[test]
171    fn fold_collides_case_and_normalization() {
172        let fold = |s: &str| RelPath::new(s).map(|p| p.fold()).ok();
173        assert_eq!(fold("README.md"), fold("readme.md"), "case must fold");
174        assert_eq!(fold("caf\u{E9}"), fold("cafe\u{301}"), "NFC and NFD must fold together");
175    }
176}