1use crate::error::{EditError, ErrorCode};
2
3pub 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#[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}