1use std::path::{Component, PathBuf};
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum PathError {
16 Empty,
18 Absolute(String),
20 Escapes(String),
22 Invalid(String),
24}
25
26impl std::fmt::Display for PathError {
27 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28 match self {
29 PathError::Empty => write!(f, "path is empty"),
30 PathError::Absolute(p) => write!(f, "path is absolute: '{}'", p),
31 PathError::Escapes(p) => write!(f, "path escapes workspace root: '{}'", p),
32 PathError::Invalid(p) => write!(f, "path contains invalid components: '{}'", p),
33 }
34 }
35}
36
37impl std::error::Error for PathError {}
38
39pub fn normalize_artifact_path(raw: &str) -> Result<String, PathError> {
63 if raw.is_empty() {
64 return Err(PathError::Empty);
65 }
66
67 if raw.contains('\0') {
69 return Err(PathError::Invalid(raw.to_string()));
70 }
71
72 let normalized = raw.replace('\\', "/");
74 let p = std::path::Path::new(&normalized);
75
76 if p.is_absolute() || normalized.starts_with('/') {
78 return Err(PathError::Absolute(raw.to_string()));
79 }
80
81 let bytes = normalized.as_bytes();
83 if bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic() {
84 return Err(PathError::Absolute(raw.to_string()));
85 }
86
87 let mut components: Vec<String> = Vec::new();
89 let mut depth: i32 = 0;
90
91 for component in p.components() {
92 match component {
93 Component::Normal(s) => {
94 let s = s.to_string_lossy().to_string();
95 components.push(s);
96 depth += 1;
97 }
98 Component::ParentDir => {
99 if depth <= 0 {
100 return Err(PathError::Escapes(raw.to_string()));
101 }
102 components.pop();
103 depth -= 1;
104 }
105 Component::CurDir => {
106 }
108 Component::RootDir | Component::Prefix(_) => {
109 return Err(PathError::Absolute(raw.to_string()));
110 }
111 }
112 }
113
114 let result: PathBuf = components.iter().collect();
115 let result_str = result.to_string_lossy().to_string();
116
117 let result_str = result_str.replace('\\', "/");
119
120 if result_str.is_empty() {
121 return Err(PathError::Empty);
122 }
123
124 Ok(result_str)
125}
126
127pub fn normalize_path_key(raw: &str) -> Option<String> {
133 normalize_artifact_path(raw).ok()
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139
140 #[test]
141 fn test_simple_relative_path() {
142 assert_eq!(
143 normalize_artifact_path("src/main.rs").unwrap(),
144 "src/main.rs"
145 );
146 }
147
148 #[test]
149 fn test_dot_prefix_stripped() {
150 assert_eq!(
151 normalize_artifact_path("./src/main.rs").unwrap(),
152 "src/main.rs"
153 );
154 }
155
156 #[test]
157 fn test_redundant_parent_resolved() {
158 assert_eq!(
159 normalize_artifact_path("src/../src/main.rs").unwrap(),
160 "src/main.rs"
161 );
162 }
163
164 #[test]
165 fn test_dot_in_middle_stripped() {
166 assert_eq!(
167 normalize_artifact_path("src/./main.rs").unwrap(),
168 "src/main.rs"
169 );
170 }
171
172 #[test]
173 fn test_multiple_slashes_normalized() {
174 assert_eq!(
175 normalize_artifact_path("src///main.rs").unwrap(),
176 "src/main.rs"
177 );
178 }
179
180 #[test]
181 fn test_backslash_normalized() {
182 assert_eq!(
183 normalize_artifact_path("src\\lib\\mod.rs").unwrap(),
184 "src/lib/mod.rs"
185 );
186 }
187
188 #[test]
189 fn test_trailing_slash_preserved_as_dir() {
190 let r = normalize_artifact_path("src/lib/").unwrap();
192 assert_eq!(r, "src/lib");
193 }
194
195 #[test]
196 fn test_empty_path_rejected() {
197 assert_eq!(normalize_artifact_path(""), Err(PathError::Empty));
198 }
199
200 #[test]
201 fn test_absolute_unix_rejected() {
202 assert!(matches!(
203 normalize_artifact_path("/etc/passwd"),
204 Err(PathError::Absolute(_))
205 ));
206 }
207
208 #[test]
209 fn test_absolute_windows_rejected() {
210 assert!(matches!(
211 normalize_artifact_path("C:\\Windows\\file.txt"),
212 Err(PathError::Absolute(_))
213 ));
214 }
215
216 #[test]
217 fn test_escape_via_dotdot_rejected() {
218 assert!(matches!(
219 normalize_artifact_path("../escape.rs"),
220 Err(PathError::Escapes(_))
221 ));
222 }
223
224 #[test]
225 fn test_deep_escape_rejected() {
226 assert!(matches!(
227 normalize_artifact_path("a/b/../../../../escape"),
228 Err(PathError::Escapes(_))
229 ));
230 }
231
232 #[test]
233 fn test_dotdot_that_stays_inside() {
234 assert_eq!(
235 normalize_artifact_path("a/b/../c/file.rs").unwrap(),
236 "a/c/file.rs"
237 );
238 }
239
240 #[test]
241 fn test_null_byte_rejected() {
242 assert!(matches!(
243 normalize_artifact_path("src/\0bad.rs"),
244 Err(PathError::Invalid(_))
245 ));
246 }
247
248 #[test]
249 fn test_just_dot_is_empty() {
250 assert_eq!(normalize_artifact_path("."), Err(PathError::Empty));
251 }
252
253 #[test]
254 fn test_normalize_path_key_returns_none_on_error() {
255 assert!(normalize_path_key("").is_none());
256 assert!(normalize_path_key("/abs").is_none());
257 assert!(normalize_path_key("../escape").is_none());
258 }
259
260 #[test]
261 fn test_normalize_path_key_returns_some_on_success() {
262 assert_eq!(
263 normalize_path_key("./src/main.rs"),
264 Some("src/main.rs".into())
265 );
266 }
267}