Skip to main content

xchecker_engine/fixup/
paths.rs

1use std::path::PathBuf;
2
3use crate::error::FixupError;
4use crate::paths::{SandboxConfig, SandboxError, SandboxRoot};
5
6/// Validates that a fixup target path is safe to apply patches to.
7///
8/// This function ensures that:
9/// - The path is not absolute
10/// - The path does not contain parent directory (`..`) components
11/// - The path is not a symlink (unless `allow_links` is true)
12/// - The path is not a hardlink (unless `allow_links` is true)
13/// - After symlink resolution, the path resolves within the repository root
14///
15/// This function delegates validation to `SandboxRoot` to keep path policy
16/// consistent across fixup parsing and application.
17///
18/// # Arguments
19///
20/// * `path` - The target path to validate (relative to repo root)
21/// * `repo_root` - The repository root directory
22/// * `allow_links` - Whether to allow symlinks and hardlinks (default: false)
23///
24/// # Returns
25///
26/// Returns `Ok(())` if the path is valid, or a `FixupError` describing why it's invalid.
27///
28/// # Examples
29///
30/// ```no_run
31/// use std::path::Path;
32/// use xchecker_engine::fixup::validate_fixup_target;
33///
34/// let repo_root = Path::new("/home/user/project");
35/// let target = Path::new("src/main.rs");
36///
37/// // Valid path
38/// assert!(validate_fixup_target(target, repo_root, false).is_ok());
39///
40/// // Invalid: absolute path
41/// let absolute = Path::new("/etc/passwd");
42/// assert!(validate_fixup_target(absolute, repo_root, false).is_err());
43///
44/// // Invalid: parent directory escape
45/// let escape = Path::new("../../../etc/passwd");
46/// assert!(validate_fixup_target(escape, repo_root, false).is_err());
47/// ```
48pub 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        // Create a test file in the repo
126        let test_file = repo_root.join("test.txt");
127        fs::write(&test_file, "test content").unwrap();
128
129        // Test absolute path rejection - use platform-appropriate absolute path
130        #[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        // Create a test file in the repo
147        let test_file = repo_root.join("test.txt");
148        fs::write(&test_file, "test content").unwrap();
149
150        // Test parent directory escape rejection
151        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        // Test another escape pattern
160        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        // Create test files in the repo
175        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        // Test valid relative paths
184        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        // Create a regular file in the repo
197        let target_file = repo_root.join("target.txt");
198        fs::write(&target_file, "target content").unwrap();
199
200        // Create a symlink inside the repo pointing to the target file
201        #[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            // Test that symlink is rejected by default
208            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            // Windows symlinks require admin privileges, so we skip if it fails
222            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        // Create a regular file in the repo
240        let target_file = repo_root.join("target.txt");
241        fs::write(&target_file, "target content").unwrap();
242
243        // Create a symlink inside the repo pointing to the target file
244        #[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            // Test that symlink is allowed with allow_links=true
251            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            // Windows symlinks require admin privileges, so we skip if it fails
261            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        // Create a regular file in the repo
275        let target_file = repo_root.join("target.txt");
276        fs::write(&target_file, "target content").unwrap();
277
278        // Create a hardlink to the target file
279        #[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            // Test that hardlink is rejected by default
285            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            // Try to create hardlink, skip if it fails (requires permissions)
299            if hard_link(&target_file, &hardlink_path).is_ok() {
300                // Test that hardlink is rejected by default
301                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        // Create a regular file in the repo
325        let target_file = repo_root.join("target.txt");
326        fs::write(&target_file, "target content").unwrap();
327
328        // Create a hardlink to the target file
329        #[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            // Test that hardlink is allowed with allow_links=true
335            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            // Try to create hardlink, skip if it fails (requires permissions)
345            if hard_link(&target_file, &hardlink_path).is_ok() {
346                // Test that hardlink is allowed with allow_links=true
347                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        // Create a directory outside the repo
367        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        // Create a symlink inside the repo pointing outside
373        #[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            // Test that symlink is rejected by default (before checking if it escapes)
380            let result =
381                validate_fixup_target(std::path::Path::new("escape_link"), repo_root, false);
382            assert!(result.is_err());
383            // Should fail with SymlinkNotAllowed before checking OutsideRepo
384            assert!(matches!(
385                result.unwrap_err(),
386                FixupError::SymlinkNotAllowed(_)
387            ));
388
389            // Test that symlink escape is detected when allow_links=true
390            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            // Windows symlinks require admin privileges, so we skip if it fails
404            if symlink_file(&outside_file, &symlink_path).is_ok() {
405                // Test that symlink is rejected by default
406                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                // Test that symlink escape is detected when allow_links=true
415                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        // Create a test file
433        let test_file = repo_root.join("Test.txt");
434        fs::write(&test_file, "test content").unwrap();
435
436        // Test that different case variations are accepted (Windows is case-insensitive)
437        let lower_case = std::path::Path::new("test.txt");
438        let result = validate_fixup_target(lower_case, repo_root, false);
439        // This should succeed because Windows paths are case-insensitive
440        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        // Test with a file that doesn't exist
453        let nonexistent = std::path::Path::new("does_not_exist.txt");
454        let result = validate_fixup_target(nonexistent, repo_root, false);
455
456        // Should fail with missing file error since the file doesn't exist
457        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        // Create a test file
470        let test_file = repo_root.join("test.txt");
471        fs::write(&test_file, "test content").unwrap();
472
473        // Test that paths with . components are accepted (they don't escape)
474        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        // Test nested . components
479        let nested_dot = std::path::Path::new("./subdir/../test.txt");
480        // This should fail because it contains .. component
481        let result2 = validate_fixup_target(nested_dot, repo_root, false);
482        assert!(result2.is_err());
483    }
484}