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 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 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}