Skip to main content

weavatrix_edit/
path.rs

1use crate::error::{EditError, ErrorCode};
2
3/// Validates a portable, repository-relative plan path.
4pub fn validate_plan_path(path: &str, max_bytes: usize) -> Result<(), EditError> {
5    if path.is_empty() {
6        return Err(invalid("file path must be non-empty"));
7    }
8    if path.len() > max_bytes {
9        return Err(invalid(format!(
10            "file path exceeds the {max_bytes}-byte limit"
11        )));
12    }
13    if path.starts_with('/') {
14        return Err(invalid("file path must be repository-relative"));
15    }
16    if path.contains('\\') {
17        return Err(invalid("file path must use forward slashes"));
18    }
19    if path.contains(':') {
20        return Err(invalid("file path may not contain ':'"));
21    }
22    if path
23        .chars()
24        .any(|character| character <= '\u{1f}' || character == '\u{7f}')
25    {
26        return Err(invalid("file path may not contain control characters"));
27    }
28
29    for segment in path.split('/') {
30        validate_segment(segment)?;
31    }
32    Ok(())
33}
34
35/// Returns a conservative key for detecting paths that alias on Windows.
36#[must_use]
37pub fn portable_path_key(path: &str) -> String {
38    let mut key = String::with_capacity(path.len());
39    for (index, segment) in path.split('/').enumerate() {
40        if index != 0 {
41            key.push('/');
42        }
43        let trimmed = segment.trim_end_matches(['.', ' ']);
44        if trimmed.is_ascii() {
45            for character in trimmed.chars() {
46                key.push(character.to_ascii_lowercase());
47            }
48        } else {
49            // `str::to_lowercase` is context-sensitive (Greek final sigma), so
50            // non-ASCII segments fold as a whole, exactly like the previous
51            // per-segment implementation.
52            key.push_str(&trimmed.to_lowercase());
53        }
54    }
55    key
56}
57
58fn validate_segment(segment: &str) -> Result<(), EditError> {
59    if segment.is_empty() {
60        return Err(invalid("file path contains an empty segment"));
61    }
62    if segment == "." || segment == ".." {
63        return Err(invalid("file path may not contain '.' or '..' segments"));
64    }
65    if segment.ends_with(['.', ' ']) {
66        return Err(invalid("file path segments may not end in dots or spaces"));
67    }
68    if segment.eq_ignore_ascii_case(".git") {
69        return Err(invalid("file path may not target .git"));
70    }
71    if is_windows_device(segment) {
72        return Err(invalid("file path may not use a Windows device name"));
73    }
74    Ok(())
75}
76
77fn is_windows_device(segment: &str) -> bool {
78    let base = segment.split('.').next().unwrap_or(segment);
79    if base.eq_ignore_ascii_case("CON")
80        || base.eq_ignore_ascii_case("PRN")
81        || base.eq_ignore_ascii_case("AUX")
82        || base.eq_ignore_ascii_case("NUL")
83        || base.eq_ignore_ascii_case("CONIN$")
84        || base.eq_ignore_ascii_case("CONOUT$")
85    {
86        return true;
87    }
88    if base.len() == 4 {
89        let bytes = base.as_bytes();
90        let prefix = &bytes[..3];
91        let suffix = bytes[3];
92        return (prefix.eq_ignore_ascii_case(b"COM") || prefix.eq_ignore_ascii_case(b"LPT"))
93            && matches!(suffix, b'1'..=b'9');
94    }
95    false
96}
97
98fn invalid(message: impl Into<String>) -> EditError {
99    EditError::new(ErrorCode::InvalidPath, message)
100}
101
102#[cfg(test)]
103mod tests {
104    #[test]
105    fn rejects_portability_and_device_aliases() {
106        for path in [
107            "",
108            "/a.rs",
109            "a\\b.rs",
110            "C:/a.rs",
111            "a//b.rs",
112            "./a.rs",
113            "../a.rs",
114            ".GIT/config",
115            "src./a.rs",
116            "src/a.rs ",
117            "NUL",
118            "con.txt",
119            "COM1.rs",
120            "src/\0bad.rs",
121        ] {
122            assert!(super::validate_plan_path(path, 4_096).is_err(), "{path:?}");
123        }
124    }
125
126    #[test]
127    fn portable_key_folds_case_and_windows_suffixes() {
128        assert_eq!(super::portable_path_key("Src/Foo.RS"), "src/foo.rs");
129        assert_eq!(super::portable_path_key("src./Foo "), "src/foo");
130    }
131
132    #[test]
133    fn portable_key_matches_the_segment_wise_lowercase_reference() {
134        for path in [
135            "src/lib.rs",
136            "Src/Foo.RS",
137            "src./Foo ",
138            "a//b.rs ",
139            "trailing/",
140            "src/模块_0001/Файл_0001.TS",
141            "greek\u{3a3}/UNIT.rs",
142            "mixed\u{130}name/ascii.rs",
143            "\u{212b}ngstr\u{f6}m/UNIT.rs",
144        ] {
145            let reference = path
146                .split('/')
147                .map(|segment| segment.trim_end_matches(['.', ' ']).to_lowercase())
148                .collect::<Vec<_>>()
149                .join("/");
150            assert_eq!(super::portable_path_key(path), reference, "{path:?}");
151        }
152    }
153}