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    path.split('/')
39        .map(|segment| segment.trim_end_matches(['.', ' ']).to_lowercase())
40        .collect::<Vec<_>>()
41        .join("/")
42}
43
44fn validate_segment(segment: &str) -> Result<(), EditError> {
45    if segment.is_empty() {
46        return Err(invalid("file path contains an empty segment"));
47    }
48    if segment == "." || segment == ".." {
49        return Err(invalid("file path may not contain '.' or '..' segments"));
50    }
51    if segment.ends_with(['.', ' ']) {
52        return Err(invalid("file path segments may not end in dots or spaces"));
53    }
54    if segment.eq_ignore_ascii_case(".git") {
55        return Err(invalid("file path may not target .git"));
56    }
57    if is_windows_device(segment) {
58        return Err(invalid("file path may not use a Windows device name"));
59    }
60    Ok(())
61}
62
63fn is_windows_device(segment: &str) -> bool {
64    let base = segment.split('.').next().unwrap_or(segment);
65    if base.eq_ignore_ascii_case("CON")
66        || base.eq_ignore_ascii_case("PRN")
67        || base.eq_ignore_ascii_case("AUX")
68        || base.eq_ignore_ascii_case("NUL")
69        || base.eq_ignore_ascii_case("CONIN$")
70        || base.eq_ignore_ascii_case("CONOUT$")
71    {
72        return true;
73    }
74    if base.len() == 4 {
75        let bytes = base.as_bytes();
76        let prefix = &bytes[..3];
77        let suffix = bytes[3];
78        return (prefix.eq_ignore_ascii_case(b"COM") || prefix.eq_ignore_ascii_case(b"LPT"))
79            && matches!(suffix, b'1'..=b'9');
80    }
81    false
82}
83
84fn invalid(message: impl Into<String>) -> EditError {
85    EditError::new(ErrorCode::InvalidPath, message)
86}
87
88#[cfg(test)]
89mod tests {
90    #[test]
91    fn rejects_portability_and_device_aliases() {
92        for path in [
93            "",
94            "/a.rs",
95            "a\\b.rs",
96            "C:/a.rs",
97            "a//b.rs",
98            "./a.rs",
99            "../a.rs",
100            ".GIT/config",
101            "src./a.rs",
102            "src/a.rs ",
103            "NUL",
104            "con.txt",
105            "COM1.rs",
106            "src/\0bad.rs",
107        ] {
108            assert!(super::validate_plan_path(path, 4_096).is_err(), "{path:?}");
109        }
110    }
111
112    #[test]
113    fn portable_key_folds_case_and_windows_suffixes() {
114        assert_eq!(super::portable_path_key("Src/Foo.RS"), "src/foo.rs");
115        assert_eq!(super::portable_path_key("src./Foo "), "src/foo");
116    }
117}