1use std::path::PathBuf;
2
3use crate::error::FixupError;
4use crate::paths::{SandboxConfig, SandboxError, SandboxRoot};
5
6pub fn validate_fixup_target(
49 path: &std::path::Path,
50 repo_root: &std::path::Path,
51 allow_links: bool,
52) -> Result<(), FixupError> {
53 let config = SandboxConfig {
54 allow_symlinks: allow_links,
55 allow_hardlinks: allow_links,
56 };
57
58 let sandbox_root = SandboxRoot::new(repo_root, config).map_err(map_root_err)?;
59 let sandbox_path = sandbox_root.join(path).map_err(map_join_err)?;
60 if !sandbox_path.as_path().exists() {
61 return Err(FixupError::TargetFileNotFound {
62 path: path.display().to_string(),
63 });
64 }
65
66 Ok(())
67}
68
69fn map_root_err(err: SandboxError) -> FixupError {
70 match err {
71 SandboxError::RootNotFound { path } | SandboxError::RootNotDirectory { path } => {
72 FixupError::CanonicalizationError(format!("Invalid repo root: {path}"))
73 }
74 SandboxError::RootCanonicalizationFailed { path, reason }
75 | SandboxError::PathCanonicalizationFailed { path, reason } => {
76 FixupError::CanonicalizationError(format!(
77 "Failed to canonicalize repo root {path}: {reason}"
78 ))
79 }
80 SandboxError::AbsolutePath { path } => FixupError::AbsolutePath(PathBuf::from(path)),
81 SandboxError::ParentTraversal { path } => FixupError::ParentDirEscape(PathBuf::from(path)),
82 SandboxError::EscapeAttempt { path, .. } => FixupError::OutsideRepo(PathBuf::from(path)),
83 SandboxError::SymlinkNotAllowed { path } => {
84 FixupError::SymlinkNotAllowed(PathBuf::from(path))
85 }
86 SandboxError::HardlinkNotAllowed { path } => {
87 FixupError::HardlinkNotAllowed(PathBuf::from(path))
88 }
89 }
90}
91
92fn map_join_err(err: SandboxError) -> FixupError {
93 match err {
94 SandboxError::AbsolutePath { path } => FixupError::AbsolutePath(PathBuf::from(path)),
95 SandboxError::ParentTraversal { path } => FixupError::ParentDirEscape(PathBuf::from(path)),
96 SandboxError::EscapeAttempt { path, .. } => FixupError::OutsideRepo(PathBuf::from(path)),
97 SandboxError::SymlinkNotAllowed { path } => {
98 FixupError::SymlinkNotAllowed(PathBuf::from(path))
99 }
100 SandboxError::HardlinkNotAllowed { path } => {
101 FixupError::HardlinkNotAllowed(PathBuf::from(path))
102 }
103 SandboxError::RootNotFound { path } | SandboxError::RootNotDirectory { path } => {
104 FixupError::CanonicalizationError(format!("Invalid repo root: {path}"))
105 }
106 SandboxError::RootCanonicalizationFailed { path, reason }
107 | SandboxError::PathCanonicalizationFailed { path, reason } => {
108 FixupError::CanonicalizationError(format!("Failed to canonicalize {path}: {reason}"))
109 }
110 }
111}
112
113#[cfg(test)]
114mod tests {
115 use super::validate_fixup_target;
116 use crate::error::FixupError;
117 use std::fs;
118 use tempfile::TempDir;
119
120 #[test]
121 fn test_validate_fixup_target_rejects_absolute_paths() {
122 let temp_dir = TempDir::new().unwrap();
123 let repo_root = temp_dir.path();
124
125 let test_file = repo_root.join("test.txt");
127 fs::write(&test_file, "test content").unwrap();
128
129 #[cfg(unix)]
131 let absolute_path = std::path::Path::new("/etc/passwd");
132
133 #[cfg(windows)]
134 let absolute_path = std::path::Path::new("C:\\Windows\\System32\\config\\sam");
135
136 let result = validate_fixup_target(absolute_path, repo_root, false);
137 assert!(result.is_err());
138 assert!(matches!(result.unwrap_err(), FixupError::AbsolutePath(_)));
139 }
140
141 #[test]
142 fn test_validate_fixup_target_rejects_parent_dir_escapes() {
143 let temp_dir = TempDir::new().unwrap();
144 let repo_root = temp_dir.path();
145
146 let test_file = repo_root.join("test.txt");
148 fs::write(&test_file, "test content").unwrap();
149
150 let escape_path = std::path::Path::new("../../../etc/passwd");
152 let result = validate_fixup_target(escape_path, repo_root, false);
153 assert!(result.is_err());
154 assert!(matches!(
155 result.unwrap_err(),
156 FixupError::ParentDirEscape(_)
157 ));
158
159 let escape_path2 = std::path::Path::new("subdir/../../outside.txt");
161 let result2 = validate_fixup_target(escape_path2, repo_root, false);
162 assert!(result2.is_err());
163 assert!(matches!(
164 result2.unwrap_err(),
165 FixupError::ParentDirEscape(_)
166 ));
167 }
168
169 #[test]
170 fn test_validate_fixup_target_accepts_valid_relative_paths() {
171 let temp_dir = TempDir::new().unwrap();
172 let repo_root = temp_dir.path();
173
174 let test_file = repo_root.join("test.txt");
176 fs::write(&test_file, "test content").unwrap();
177
178 let subdir = repo_root.join("subdir");
179 fs::create_dir(&subdir).unwrap();
180 let nested_file = subdir.join("nested.txt");
181 fs::write(&nested_file, "nested content").unwrap();
182
183 let valid_path1 = std::path::Path::new("test.txt");
185 assert!(validate_fixup_target(valid_path1, repo_root, false).is_ok());
186
187 let valid_path2 = std::path::Path::new("subdir/nested.txt");
188 assert!(validate_fixup_target(valid_path2, repo_root, false).is_ok());
189 }
190
191 #[test]
192 fn test_validate_fixup_target_rejects_symlinks_by_default() {
193 let temp_dir = TempDir::new().unwrap();
194 let repo_root = temp_dir.path();
195
196 let target_file = repo_root.join("target.txt");
198 fs::write(&target_file, "target content").unwrap();
199
200 #[cfg(unix)]
202 {
203 use std::os::unix::fs::symlink;
204 let symlink_path = repo_root.join("link_to_target");
205 symlink(&target_file, &symlink_path).unwrap();
206
207 let result =
209 validate_fixup_target(std::path::Path::new("link_to_target"), repo_root, false);
210 assert!(result.is_err());
211 assert!(matches!(
212 result.unwrap_err(),
213 FixupError::SymlinkNotAllowed(_)
214 ));
215 }
216
217 #[cfg(windows)]
218 {
219 use std::os::windows::fs::symlink_file;
220 let symlink_path = repo_root.join("link_to_target");
221 if symlink_file(&target_file, &symlink_path).is_ok() {
223 let result =
224 validate_fixup_target(std::path::Path::new("link_to_target"), repo_root, false);
225 assert!(result.is_err());
226 assert!(matches!(
227 result.unwrap_err(),
228 FixupError::SymlinkNotAllowed(_)
229 ));
230 }
231 }
232 }
233
234 #[test]
235 fn test_validate_fixup_target_allows_symlinks_with_flag() {
236 let temp_dir = TempDir::new().unwrap();
237 let repo_root = temp_dir.path();
238
239 let target_file = repo_root.join("target.txt");
241 fs::write(&target_file, "target content").unwrap();
242
243 #[cfg(unix)]
245 {
246 use std::os::unix::fs::symlink;
247 let symlink_path = repo_root.join("link_to_target");
248 symlink(&target_file, &symlink_path).unwrap();
249
250 let result =
252 validate_fixup_target(std::path::Path::new("link_to_target"), repo_root, true);
253 assert!(result.is_ok());
254 }
255
256 #[cfg(windows)]
257 {
258 use std::os::windows::fs::symlink_file;
259 let symlink_path = repo_root.join("link_to_target");
260 if symlink_file(&target_file, &symlink_path).is_ok() {
262 let result =
263 validate_fixup_target(std::path::Path::new("link_to_target"), repo_root, true);
264 assert!(result.is_ok());
265 }
266 }
267 }
268
269 #[test]
270 fn test_validate_fixup_target_rejects_hardlinks_by_default() {
271 let temp_dir = TempDir::new().unwrap();
272 let repo_root = temp_dir.path();
273
274 let target_file = repo_root.join("target.txt");
276 fs::write(&target_file, "target content").unwrap();
277
278 #[cfg(unix)]
280 {
281 let hardlink_path = repo_root.join("hardlink_to_target");
282 std::fs::hard_link(&target_file, &hardlink_path).unwrap();
283
284 let result =
286 validate_fixup_target(std::path::Path::new("hardlink_to_target"), repo_root, false);
287 assert!(result.is_err());
288 assert!(matches!(
289 result.unwrap_err(),
290 FixupError::HardlinkNotAllowed(_)
291 ));
292 }
293
294 #[cfg(windows)]
295 {
296 use std::fs::hard_link;
297 let hardlink_path = repo_root.join("hardlink_to_target");
298 if hard_link(&target_file, &hardlink_path).is_ok() {
300 let result = validate_fixup_target(
302 std::path::Path::new("hardlink_to_target"),
303 repo_root,
304 false,
305 );
306 assert!(result.is_err());
307 assert!(matches!(
308 result.unwrap_err(),
309 FixupError::HardlinkNotAllowed(_)
310 ));
311 } else {
312 println!(
313 "Skipping hardlink rejection test on Windows (creating hardlink requires elevated permissions)"
314 );
315 }
316 }
317 }
318
319 #[test]
320 fn test_validate_fixup_target_allows_hardlinks_with_flag() {
321 let temp_dir = TempDir::new().unwrap();
322 let repo_root = temp_dir.path();
323
324 let target_file = repo_root.join("target.txt");
326 fs::write(&target_file, "target content").unwrap();
327
328 #[cfg(unix)]
330 {
331 let hardlink_path = repo_root.join("hardlink_to_target");
332 std::fs::hard_link(&target_file, &hardlink_path).unwrap();
333
334 let result =
336 validate_fixup_target(std::path::Path::new("hardlink_to_target"), repo_root, true);
337 assert!(result.is_ok());
338 }
339
340 #[cfg(windows)]
341 {
342 use std::fs::hard_link;
343 let hardlink_path = repo_root.join("hardlink_to_target");
344 if hard_link(&target_file, &hardlink_path).is_ok() {
346 let result = validate_fixup_target(
348 std::path::Path::new("hardlink_to_target"),
349 repo_root,
350 true,
351 );
352 assert!(result.is_ok());
353 } else {
354 println!(
355 "Skipping hardlink allow test on Windows (creating hardlink requires elevated permissions)"
356 );
357 }
358 }
359 }
360
361 #[test]
362 fn test_validate_fixup_target_symlink_escape() {
363 let temp_dir = TempDir::new().unwrap();
364 let repo_root = temp_dir.path();
365
366 let outside_dir = temp_dir.path().parent().unwrap().join("outside");
368 fs::create_dir_all(&outside_dir).unwrap();
369 let outside_file = outside_dir.join("secret.txt");
370 fs::write(&outside_file, "secret content").unwrap();
371
372 #[cfg(unix)]
374 {
375 use std::os::unix::fs::symlink;
376 let symlink_path = repo_root.join("escape_link");
377 let _ = symlink(&outside_file, &symlink_path);
378
379 let result =
381 validate_fixup_target(std::path::Path::new("escape_link"), repo_root, false);
382 assert!(result.is_err());
383 assert!(matches!(
385 result.unwrap_err(),
386 FixupError::SymlinkNotAllowed(_)
387 ));
388
389 let result_with_links =
391 validate_fixup_target(std::path::Path::new("escape_link"), repo_root, true);
392 assert!(result_with_links.is_err());
393 assert!(matches!(
394 result_with_links.unwrap_err(),
395 FixupError::OutsideRepo(_)
396 ));
397 }
398
399 #[cfg(windows)]
400 {
401 use std::os::windows::fs::symlink_file;
402 let symlink_path = repo_root.join("escape_link");
403 if symlink_file(&outside_file, &symlink_path).is_ok() {
405 let result =
407 validate_fixup_target(std::path::Path::new("escape_link"), repo_root, false);
408 assert!(result.is_err());
409 assert!(matches!(
410 result.unwrap_err(),
411 FixupError::SymlinkNotAllowed(_)
412 ));
413
414 let result_with_links =
416 validate_fixup_target(std::path::Path::new("escape_link"), repo_root, true);
417 assert!(result_with_links.is_err());
418 assert!(matches!(
419 result_with_links.unwrap_err(),
420 FixupError::OutsideRepo(_)
421 ));
422 }
423 }
424 }
425
426 #[test]
427 #[cfg(windows)]
428 fn test_validate_fixup_target_windows_case_insensitive() {
429 let temp_dir = TempDir::new().unwrap();
430 let repo_root = temp_dir.path();
431
432 let test_file = repo_root.join("Test.txt");
434 fs::write(&test_file, "test content").unwrap();
435
436 let lower_case = std::path::Path::new("test.txt");
438 let result = validate_fixup_target(lower_case, repo_root, false);
439 assert!(result.is_ok());
441
442 let upper_case = std::path::Path::new("TEST.TXT");
443 let result2 = validate_fixup_target(upper_case, repo_root, false);
444 assert!(result2.is_ok());
445 }
446
447 #[test]
448 fn test_validate_fixup_target_nonexistent_file() {
449 let temp_dir = TempDir::new().unwrap();
450 let repo_root = temp_dir.path();
451
452 let nonexistent = std::path::Path::new("does_not_exist.txt");
454 let result = validate_fixup_target(nonexistent, repo_root, false);
455
456 assert!(result.is_err());
458 assert!(matches!(
459 result.unwrap_err(),
460 FixupError::TargetFileNotFound { .. }
461 ));
462 }
463
464 #[test]
465 fn test_validate_fixup_target_with_dot_components() {
466 let temp_dir = TempDir::new().unwrap();
467 let repo_root = temp_dir.path();
468
469 let test_file = repo_root.join("test.txt");
471 fs::write(&test_file, "test content").unwrap();
472
473 let dot_path = std::path::Path::new("./test.txt");
475 let result = validate_fixup_target(dot_path, repo_root, false);
476 assert!(result.is_ok());
477
478 let nested_dot = std::path::Path::new("./subdir/../test.txt");
480 let result2 = validate_fixup_target(nested_dot, repo_root, false);
482 assert!(result2.is_err());
483 }
484}