Skip to main content

xchecker_utils/
paths.rs

1use camino::Utf8PathBuf;
2use std::cell::RefCell;
3use std::path::{Path, PathBuf};
4use thiserror::Error;
5
6// Thread-local override used only in tests to avoid process-global env races.
7thread_local! {
8    static THREAD_HOME: RefCell<Option<Utf8PathBuf>> = const { RefCell::new(None) };
9}
10
11// ============================================================================
12// Platform-Specific Hardlink Detection
13// ============================================================================
14
15/// Get the link count for a file.
16///
17/// Returns the number of hard links pointing to the file. A regular file
18/// without hardlinks has a link count of 1. A link count > 1 indicates
19/// the file has hard links.
20///
21/// # Fail-Closed Behavior
22///
23/// If the link count cannot be determined (e.g., permission denied, file
24/// cannot be opened), this function returns `Err`. Callers should treat
25/// errors as potential hardlinks for security (fail closed).
26///
27/// # Platform Behavior
28///
29/// - **Unix**: Uses `metadata.nlink()` via `MetadataExt`
30/// - **Windows**: Uses `GetFileInformationByHandle` Win32 API to get `nNumberOfLinks`
31///
32/// # Arguments
33///
34/// * `path` - The path to check. Must be a regular file (not a directory).
35///
36/// # Returns
37///
38/// * `Ok(n)` - The file has `n` hard links
39/// * `Err(e)` - Could not determine link count (treat as potential hardlink)
40#[cfg(unix)]
41pub fn link_count(path: &Path) -> Result<u32, std::io::Error> {
42    use std::os::unix::fs::MetadataExt;
43    let metadata = path.metadata()?;
44    // nlink() returns u64 on Unix, but link counts > u32::MAX are unrealistic
45    Ok(metadata.nlink() as u32)
46}
47
48#[cfg(windows)]
49pub fn link_count(path: &Path) -> Result<u32, std::io::Error> {
50    use std::fs::File;
51    use std::os::windows::io::AsRawHandle;
52    use windows::Win32::Foundation::HANDLE;
53    use windows::Win32::Storage::FileSystem::{
54        BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
55    };
56
57    // Open the file to get a handle
58    let file = File::open(path)?;
59
60    let handle = HANDLE(file.as_raw_handle());
61    let mut file_info = BY_HANDLE_FILE_INFORMATION::default();
62
63    // Get file information including nNumberOfLinks
64    let result = unsafe { GetFileInformationByHandle(handle, &mut file_info) };
65
66    match result {
67        Ok(()) => Ok(file_info.nNumberOfLinks),
68        Err(e) => Err(std::io::Error::other(format!(
69            "GetFileInformationByHandle failed: {e}"
70        ))),
71    }
72}
73
74// ============================================================================
75// Sandbox Error Types
76// ============================================================================
77
78/// Errors that can occur during path sandbox operations.
79///
80/// These errors indicate security violations when paths attempt to escape
81/// their designated sandbox root.
82#[derive(Error, Debug, Clone, PartialEq, Eq)]
83pub enum SandboxError {
84    /// The sandbox root path does not exist
85    #[error("Sandbox root does not exist: {path}")]
86    RootNotFound { path: String },
87
88    /// The sandbox root path is not a directory
89    #[error("Sandbox root is not a directory: {path}")]
90    RootNotDirectory { path: String },
91
92    /// Failed to canonicalize the sandbox root path
93    #[error("Failed to canonicalize sandbox root '{path}': {reason}")]
94    RootCanonicalizationFailed { path: String, reason: String },
95
96    /// Path contains ".." traversal components
97    #[error("Path contains parent directory traversal: {path}")]
98    ParentTraversal { path: String },
99
100    /// Path is absolute and not within the sandbox root
101    #[error("Absolute path not allowed: {path}")]
102    AbsolutePath { path: String },
103
104    /// Path resolves outside the sandbox root
105    #[error("Path escapes sandbox root: {path} resolves outside {root}")]
106    EscapeAttempt { path: String, root: String },
107
108    /// Path is or contains a symlink (when symlinks are not allowed)
109    #[error("Symlink not allowed: {path}")]
110    SymlinkNotAllowed { path: String },
111
112    /// Path is or contains a hardlink (when hardlinks are not allowed)
113    #[error("Hardlink not allowed: {path}")]
114    HardlinkNotAllowed { path: String },
115
116    /// Failed to canonicalize the joined path
117    #[error("Failed to canonicalize path '{path}': {reason}")]
118    PathCanonicalizationFailed { path: String, reason: String },
119}
120
121// ============================================================================
122// Sandbox Configuration
123// ============================================================================
124
125/// Configuration for sandbox path validation behavior.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
127pub struct SandboxConfig {
128    /// Whether to allow symlinks within the sandbox
129    pub allow_symlinks: bool,
130    /// Whether to allow hardlinks within the sandbox (files with link count > 1)
131    pub allow_hardlinks: bool,
132}
133
134impl SandboxConfig {
135    /// Create a permissive config that allows symlinks and hardlinks
136    #[must_use]
137    pub fn permissive() -> Self {
138        Self {
139            allow_symlinks: true,
140            allow_hardlinks: true,
141        }
142    }
143}
144
145// ============================================================================
146// SandboxRoot - Validated root directory for sandboxed operations
147// ============================================================================
148
149/// A validated root directory for sandboxed operations.
150///
151/// All paths derived from this root are guaranteed to stay within it.
152/// `SandboxRoot` canonicalizes the root path at construction time and
153/// validates all joined paths to prevent directory traversal attacks.
154///
155/// # Security Guarantees
156///
157/// - The root path is canonicalized (resolved to absolute, symlinks followed)
158/// - Joined paths cannot escape the root via `..` traversal
159/// - Absolute paths are rejected unless they're within the root
160/// - Symlinks can be optionally rejected to prevent escape via symlink
161///
162/// # Example
163///
164/// ```rust,no_run
165/// use xchecker_utils::paths::{SandboxRoot, SandboxConfig};
166///
167/// let root = SandboxRoot::new("/path/to/workspace", SandboxConfig::default())?;
168/// let file = root.join("src/main.rs")?;
169/// println!("Safe path: {}", file.as_path().display());
170/// # Ok::<(), xchecker_utils::paths::SandboxError>(())
171/// ```
172#[derive(Debug, Clone)]
173pub struct SandboxRoot {
174    /// Canonicalized absolute path to the root
175    root: PathBuf,
176    /// Configuration for path validation
177    config: SandboxConfig,
178}
179
180impl SandboxRoot {
181    /// Create a new sandbox root from a path.
182    ///
183    /// Canonicalizes the path and verifies it exists as a directory.
184    ///
185    /// # Arguments
186    ///
187    /// * `root` - The path to use as the sandbox root
188    /// * `config` - Configuration for symlink/hardlink handling
189    ///
190    /// # Errors
191    ///
192    /// Returns an error if:
193    /// - The path does not exist
194    /// - The path is not a directory
195    /// - The path cannot be canonicalized
196    pub fn new(root: impl AsRef<Path>, config: SandboxConfig) -> Result<Self, SandboxError> {
197        let root_path = root.as_ref();
198
199        // Check existence
200        if !root_path.exists() {
201            return Err(SandboxError::RootNotFound {
202                path: root_path.display().to_string(),
203            });
204        }
205
206        // Check it's a directory
207        if !root_path.is_dir() {
208            return Err(SandboxError::RootNotDirectory {
209                path: root_path.display().to_string(),
210            });
211        }
212
213        // Canonicalize to get absolute path with symlinks resolved
214        let canonical =
215            root_path
216                .canonicalize()
217                .map_err(|e| SandboxError::RootCanonicalizationFailed {
218                    path: root_path.display().to_string(),
219                    reason: e.to_string(),
220                })?;
221
222        Ok(Self {
223            root: canonical,
224            config,
225        })
226    }
227
228    /// Create a sandbox root with default (restrictive) configuration.
229    ///
230    /// This is a convenience method equivalent to `SandboxRoot::new(root, SandboxConfig::default())`.
231    pub fn new_default(root: impl AsRef<Path>) -> Result<Self, SandboxError> {
232        Self::new(root, SandboxConfig::default())
233    }
234
235    /// Join a relative path, validating it stays within the sandbox.
236    ///
237    /// # Arguments
238    ///
239    /// * `rel` - A relative path to join to the sandbox root
240    ///
241    /// # Errors
242    ///
243    /// Returns an error if:
244    /// - The path contains `..` traversal components
245    /// - The path is absolute
246    /// - The resolved path escapes the sandbox root
247    /// - The path is or contains a symlink (when symlinks are not allowed)
248    /// - The path is or contains a hardlink (when hardlinks are not allowed)
249    pub fn join(&self, rel: impl AsRef<Path>) -> Result<SandboxPath, SandboxError> {
250        let rel_path = rel.as_ref();
251
252        // Reject absolute paths
253        if rel_path.is_absolute() {
254            return Err(SandboxError::AbsolutePath {
255                path: rel_path.display().to_string(),
256            });
257        }
258
259        // Reject paths with ".." components (before any filesystem operations)
260        if rel_path
261            .components()
262            .any(|c| matches!(c, std::path::Component::ParentDir))
263        {
264            return Err(SandboxError::ParentTraversal {
265                path: rel_path.display().to_string(),
266            });
267        }
268
269        // Build the full path
270        let full_path = self.root.join(rel_path);
271
272        // Check symlink status before canonicalization if symlinks are not allowed
273        if !self.config.allow_symlinks {
274            self.check_symlinks_in_path(&full_path)?;
275        }
276
277        // If the path exists, canonicalize and verify it's within the root
278        if full_path.exists() {
279            let canonical =
280                full_path
281                    .canonicalize()
282                    .map_err(|e| SandboxError::PathCanonicalizationFailed {
283                        path: full_path.display().to_string(),
284                        reason: e.to_string(),
285                    })?;
286
287            // Verify the canonical path is within the sandbox root
288            if !canonical.starts_with(&self.root) {
289                return Err(SandboxError::EscapeAttempt {
290                    path: rel_path.display().to_string(),
291                    root: self.root.display().to_string(),
292                });
293            }
294
295            // Check hardlink status if hardlinks are not allowed
296            if !self.config.allow_hardlinks {
297                self.check_hardlink(&canonical)?;
298            }
299
300            Ok(SandboxPath {
301                full: canonical,
302                rel: rel_path.to_path_buf(),
303            })
304        } else {
305            // For non-existent paths, we need to ensure that existing ancestor
306            // directories don't escape the sandbox via symlinks.
307            // This is critical when allow_symlinks is true - a symlink directory
308            // in the path could redirect to outside the sandbox.
309            if self.config.allow_symlinks {
310                self.validate_ancestor_within_sandbox(&full_path, rel_path)?;
311            }
312
313            // The full path is root + rel, which is now guaranteed to be within root
314            Ok(SandboxPath {
315                full: full_path,
316                rel: rel_path.to_path_buf(),
317            })
318        }
319    }
320
321    /// Check if any component in the path is a symlink.
322    fn check_symlinks_in_path(&self, path: &Path) -> Result<(), SandboxError> {
323        let mut current = PathBuf::new();
324
325        for component in path.components() {
326            current.push(component);
327
328            // Only check if the path exists
329            if current.exists() {
330                // Check if this component is a symlink
331                if current
332                    .symlink_metadata()
333                    .map(|m| m.is_symlink())
334                    .unwrap_or(false)
335                {
336                    return Err(SandboxError::SymlinkNotAllowed {
337                        path: current.display().to_string(),
338                    });
339                }
340            }
341        }
342
343        Ok(())
344    }
345
346    /// Check if a file is a hardlink (has link count > 1).
347    ///
348    /// Uses fail-closed behavior: if link count cannot be determined,
349    /// treats the file as a potential hardlink and rejects it.
350    fn check_hardlink(&self, path: &Path) -> Result<(), SandboxError> {
351        // Only check files, not directories
352        if path.is_file() {
353            match link_count(path) {
354                Ok(count) if count > 1 => {
355                    return Err(SandboxError::HardlinkNotAllowed {
356                        path: path.display().to_string(),
357                    });
358                }
359                Ok(_) => {
360                    // Link count is 1, not a hardlink
361                }
362                Err(_) => {
363                    // Fail closed: if we can't determine link count, assume it might be a hardlink
364                    return Err(SandboxError::HardlinkNotAllowed {
365                        path: path.display().to_string(),
366                    });
367                }
368            }
369        }
370
371        Ok(())
372    }
373
374    /// Validate that the nearest existing ancestor of a non-existent path
375    /// stays within the sandbox when canonicalized.
376    ///
377    /// This prevents symlink traversal attacks where a symlinked directory
378    /// in the path points outside the sandbox, allowing creation of files
379    /// outside the intended directory.
380    ///
381    /// # Security
382    ///
383    /// When `allow_symlinks` is true and a path doesn't exist, we must verify
384    /// that existing parent directories don't escape via symlinks. Without this
385    /// check, `root.join("symlinked_dir/new_file.txt")` could create a file
386    /// outside the sandbox if `symlinked_dir` points elsewhere.
387    fn validate_ancestor_within_sandbox(
388        &self,
389        full_path: &Path,
390        rel_path: &Path,
391    ) -> Result<(), SandboxError> {
392        // Find the longest prefix of full_path that exists
393        let mut ancestor = full_path.to_path_buf();
394        while !ancestor.exists() {
395            if !ancestor.pop() {
396                // We've popped all the way to the root, nothing to check
397                return Ok(());
398            }
399        }
400
401        // Canonicalize the existing ancestor and verify containment
402        let canonical_ancestor =
403            ancestor
404                .canonicalize()
405                .map_err(|e| SandboxError::PathCanonicalizationFailed {
406                    path: ancestor.display().to_string(),
407                    reason: e.to_string(),
408                })?;
409
410        // Verify the canonical ancestor is within the sandbox root
411        if !canonical_ancestor.starts_with(&self.root) {
412            return Err(SandboxError::EscapeAttempt {
413                path: rel_path.display().to_string(),
414                root: self.root.display().to_string(),
415            });
416        }
417
418        Ok(())
419    }
420
421    /// Get the canonicalized root path.
422    #[must_use]
423    pub fn as_path(&self) -> &Path {
424        &self.root
425    }
426
427    /// Get the sandbox configuration.
428    #[must_use]
429    pub fn config(&self) -> &SandboxConfig {
430        &self.config
431    }
432}
433
434// ============================================================================
435// SandboxPath - A validated path within a SandboxRoot
436// ============================================================================
437
438/// A path that has been validated to be within a `SandboxRoot`.
439///
440/// Cannot be constructed directly; must come from [`SandboxRoot::join()`].
441/// This type guarantees that the path:
442/// - Does not escape the sandbox root
443/// - Does not contain `..` traversal components
444/// - Is not an absolute path outside the root
445/// - Does not contain symlinks (if configured)
446///
447/// # Example
448///
449/// ```rust,no_run
450/// use xchecker_utils::paths::{SandboxRoot, SandboxConfig};
451///
452/// let root = SandboxRoot::new("/workspace", SandboxConfig::default())?;
453/// let path = root.join("src/lib.rs")?;
454///
455/// // Use the full path for I/O operations
456/// let content = std::fs::read_to_string(path.as_path())?;
457///
458/// // Use the relative path for display or storage
459/// println!("File: {}", path.relative().display());
460/// # Ok::<(), Box<dyn std::error::Error>>(())
461/// ```
462#[derive(Debug, Clone)]
463pub struct SandboxPath {
464    /// Full path (root + relative)
465    full: PathBuf,
466    /// Relative path from root
467    rel: PathBuf,
468}
469
470impl SandboxPath {
471    /// Get the full path for I/O operations.
472    ///
473    /// This returns the complete path including the sandbox root,
474    /// suitable for use with `std::fs` operations.
475    #[must_use]
476    pub fn as_path(&self) -> &Path {
477        &self.full
478    }
479
480    /// Get the relative portion of the path.
481    ///
482    /// This returns the path relative to the sandbox root,
483    /// suitable for display or storage in artifacts.
484    #[must_use]
485    pub fn relative(&self) -> &Path {
486        &self.rel
487    }
488
489    /// Convert to a `PathBuf` for ownership.
490    #[must_use]
491    pub fn to_path_buf(&self) -> PathBuf {
492        self.full.clone()
493    }
494
495    /// Convert the relative path to a `PathBuf`.
496    #[must_use]
497    pub fn relative_to_path_buf(&self) -> PathBuf {
498        self.rel.clone()
499    }
500}
501
502impl AsRef<Path> for SandboxPath {
503    fn as_ref(&self) -> &Path {
504        &self.full
505    }
506}
507
508/// Resolve xchecker home:
509/// 1) thread-local override (tests use this)
510/// 2) env `XCHECKER_HOME` (opt-in for users/CI)
511/// 3) default ".xchecker"
512#[must_use]
513pub fn xchecker_home() -> Utf8PathBuf {
514    if let Some(tl) = THREAD_HOME.with(|tl| tl.borrow().clone()) {
515        return tl;
516    }
517    if let Ok(p) = std::env::var("XCHECKER_HOME") {
518        return Utf8PathBuf::from(p);
519    }
520    Utf8PathBuf::from(".xchecker")
521}
522
523/// Returns `<XCHECKER_HOME>/specs/<spec_id>`
524#[must_use]
525pub fn spec_root(spec_id: &str) -> Utf8PathBuf {
526    xchecker_home().join("specs").join(spec_id)
527}
528
529/// Returns `<XCHECKER_HOME>/cache`
530#[must_use]
531pub fn cache_dir() -> Utf8PathBuf {
532    xchecker_home().join("cache")
533}
534
535/// mkdir -p; treat `AlreadyExists` as success (removes TOCTTOU races)
536pub fn ensure_dir_all<P: AsRef<std::path::Path>>(p: P) -> std::io::Result<()> {
537    match std::fs::create_dir_all(&p) {
538        Ok(()) => Ok(()),
539        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
540        Err(e) => Err(e),
541    }
542}
543
544/// Test helper: provides isolated workspace testing; not part of public API stability guarantees.
545///
546/// Give this test a unique home under the system temp dir.
547/// Hold the `TempDir` for the test's duration so the directory stays alive.
548#[cfg(any(test, feature = "test-utils"))]
549#[cfg_attr(not(test), allow(dead_code))]
550#[must_use]
551pub fn with_isolated_home() -> tempfile::TempDir {
552    let td = tempfile::TempDir::new().expect("create temp home");
553    let p = Utf8PathBuf::from_path_buf(td.path().to_path_buf()).unwrap();
554    THREAD_HOME.with(|tl| *tl.borrow_mut() = Some(p.clone()));
555    #[cfg(feature = "test-utils")]
556    {
557        xchecker_lock::set_thread_home_for_tests(p);
558    }
559    td
560}
561
562// ============================================================================
563// Tests
564// ============================================================================
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569    use tempfile::TempDir;
570
571    fn create_test_dir() -> TempDir {
572        TempDir::new().expect("Failed to create temp dir")
573    }
574
575    // ========================================================================
576    // SandboxRoot::new() tests
577    // ========================================================================
578
579    #[test]
580    fn test_sandbox_root_new_valid_directory() {
581        let temp = create_test_dir();
582        let root = SandboxRoot::new(temp.path(), SandboxConfig::default());
583        assert!(root.is_ok());
584        let root = root.unwrap();
585        assert!(root.as_path().is_absolute());
586    }
587
588    #[test]
589    fn test_sandbox_root_new_nonexistent_path() {
590        let result = SandboxRoot::new(
591            "/nonexistent/path/that/does/not/exist",
592            SandboxConfig::default(),
593        );
594        assert!(result.is_err());
595        assert!(matches!(
596            result.unwrap_err(),
597            SandboxError::RootNotFound { .. }
598        ));
599    }
600
601    #[test]
602    fn test_sandbox_root_new_file_not_directory() {
603        let temp = create_test_dir();
604        let file_path = temp.path().join("file.txt");
605        std::fs::write(&file_path, "content").unwrap();
606
607        let result = SandboxRoot::new(&file_path, SandboxConfig::default());
608        assert!(result.is_err());
609        assert!(matches!(
610            result.unwrap_err(),
611            SandboxError::RootNotDirectory { .. }
612        ));
613    }
614
615    #[test]
616    fn test_sandbox_root_new_default() {
617        let temp = create_test_dir();
618        let root = SandboxRoot::new_default(temp.path());
619        assert!(root.is_ok());
620    }
621
622    // ========================================================================
623    // SandboxRoot::join() - basic functionality
624    // ========================================================================
625
626    #[test]
627    fn test_sandbox_join_simple_relative_path() {
628        let temp = create_test_dir();
629        let subdir = temp.path().join("subdir");
630        std::fs::create_dir(&subdir).unwrap();
631        let file = subdir.join("file.txt");
632        std::fs::write(&file, "content").unwrap();
633
634        let root = SandboxRoot::new_default(temp.path()).unwrap();
635        let result = root.join("subdir/file.txt");
636        assert!(result.is_ok());
637        let sandbox_path = result.unwrap();
638        assert_eq!(sandbox_path.relative(), Path::new("subdir/file.txt"));
639    }
640
641    #[test]
642    fn test_sandbox_join_nonexistent_path_allowed() {
643        let temp = create_test_dir();
644        let root = SandboxRoot::new_default(temp.path()).unwrap();
645
646        // Non-existent paths are allowed (for creating new files)
647        let result = root.join("new/path/to/file.txt");
648        assert!(result.is_ok());
649    }
650
651    // ========================================================================
652    // SandboxRoot::join() - rejection of ".." traversal
653    // ========================================================================
654
655    #[test]
656    fn test_sandbox_join_rejects_parent_traversal() {
657        let temp = create_test_dir();
658        let root = SandboxRoot::new_default(temp.path()).unwrap();
659
660        let result = root.join("../escape");
661        assert!(result.is_err());
662        assert!(matches!(
663            result.unwrap_err(),
664            SandboxError::ParentTraversal { .. }
665        ));
666    }
667
668    #[test]
669    fn test_sandbox_join_rejects_hidden_parent_traversal() {
670        let temp = create_test_dir();
671        let root = SandboxRoot::new_default(temp.path()).unwrap();
672
673        let result = root.join("subdir/../../../escape");
674        assert!(result.is_err());
675        assert!(matches!(
676            result.unwrap_err(),
677            SandboxError::ParentTraversal { .. }
678        ));
679    }
680
681    #[test]
682    fn test_sandbox_join_rejects_parent_at_end() {
683        let temp = create_test_dir();
684        let root = SandboxRoot::new_default(temp.path()).unwrap();
685
686        let result = root.join("subdir/..");
687        assert!(result.is_err());
688        assert!(matches!(
689            result.unwrap_err(),
690            SandboxError::ParentTraversal { .. }
691        ));
692    }
693
694    // ========================================================================
695    // SandboxRoot::join() - rejection of absolute paths
696    // ========================================================================
697
698    #[test]
699    fn test_sandbox_join_rejects_absolute_path() {
700        let temp = create_test_dir();
701        let root = SandboxRoot::new_default(temp.path()).unwrap();
702
703        #[cfg(unix)]
704        let result = root.join("/etc/passwd");
705        #[cfg(windows)]
706        let result = root.join("C:\\Windows\\System32");
707
708        assert!(result.is_err());
709        assert!(matches!(
710            result.unwrap_err(),
711            SandboxError::AbsolutePath { .. }
712        ));
713    }
714
715    // ========================================================================
716    // SandboxRoot::join() - symlink handling
717    // ========================================================================
718
719    #[cfg(unix)]
720    #[test]
721    fn test_sandbox_join_rejects_symlink_by_default() {
722        let temp = create_test_dir();
723        let target = temp.path().join("target.txt");
724        std::fs::write(&target, "content").unwrap();
725
726        let link = temp.path().join("link.txt");
727        std::os::unix::fs::symlink(&target, &link).unwrap();
728
729        let root = SandboxRoot::new_default(temp.path()).unwrap();
730        let result = root.join("link.txt");
731        assert!(result.is_err());
732        assert!(matches!(
733            result.unwrap_err(),
734            SandboxError::SymlinkNotAllowed { .. }
735        ));
736    }
737
738    #[cfg(unix)]
739    #[test]
740    fn test_sandbox_join_allows_symlink_when_configured() {
741        let temp = create_test_dir();
742        let target = temp.path().join("target.txt");
743        std::fs::write(&target, "content").unwrap();
744
745        let link = temp.path().join("link.txt");
746        std::os::unix::fs::symlink(&target, &link).unwrap();
747
748        let config = SandboxConfig::permissive();
749        let root = SandboxRoot::new(temp.path(), config).unwrap();
750        let result = root.join("link.txt");
751        assert!(result.is_ok());
752    }
753
754    #[cfg(unix)]
755    #[test]
756    fn test_sandbox_join_rejects_symlink_escape() {
757        let temp = create_test_dir();
758        let outside = TempDir::new().unwrap();
759        let outside_file = outside.path().join("secret.txt");
760        std::fs::write(&outside_file, "secret").unwrap();
761
762        // Create a symlink inside the sandbox pointing outside
763        let link = temp.path().join("escape_link");
764        std::os::unix::fs::symlink(&outside_file, &link).unwrap();
765
766        // Even with symlinks allowed, escape should be detected
767        let config = SandboxConfig::permissive();
768        let root = SandboxRoot::new(temp.path(), config).unwrap();
769        let result = root.join("escape_link");
770        assert!(result.is_err());
771        assert!(matches!(
772            result.unwrap_err(),
773            SandboxError::EscapeAttempt { .. }
774        ));
775    }
776
777    /// Regression test for symlink traversal via non-existent paths.
778    ///
779    /// This tests the vulnerability where a symlinked directory inside the sandbox
780    /// points outside, and the attacker creates a non-existent file under it.
781    /// Since the final path doesn't exist, the old code skipped canonicalization,
782    /// allowing the escape.
783    ///
784    /// Attack scenario:
785    /// 1. Sandbox at /sandbox
786    /// 2. /sandbox/escape_dir -> /tmp/attacker (symlink to outside)
787    /// 3. Attacker calls root.join("escape_dir/malicious.txt")
788    /// 4. Old code: path doesn't exist, skip canonicalization, allow it
789    /// 5. Fixed code: canonicalize ancestor (escape_dir), detect escape
790    #[cfg(unix)]
791    #[test]
792    fn test_sandbox_join_rejects_symlink_dir_escape_via_nonexistent_path() {
793        let temp = create_test_dir();
794        let outside = TempDir::new().unwrap();
795
796        // Create a directory outside the sandbox
797        let outside_dir = outside.path().join("attacker_controlled");
798        std::fs::create_dir(&outside_dir).unwrap();
799
800        // Create a symlink inside the sandbox pointing to the outside directory
801        let escape_link = temp.path().join("escape_dir");
802        std::os::unix::fs::symlink(&outside_dir, &escape_link).unwrap();
803
804        // With symlinks allowed, try to join a NON-EXISTENT file under the symlinked dir
805        // This is the vulnerability: the final path doesn't exist, so old code
806        // would skip canonicalization and allow it
807        let config = SandboxConfig::permissive();
808        let root = SandboxRoot::new(temp.path(), config).unwrap();
809
810        // This should be rejected - escape_dir resolves outside the sandbox
811        let result = root.join("escape_dir/nonexistent_malicious_file.txt");
812        assert!(
813            result.is_err(),
814            "Expected escape to be detected for non-existent path through symlinked directory"
815        );
816        assert!(matches!(
817            result.unwrap_err(),
818            SandboxError::EscapeAttempt { .. }
819        ));
820    }
821
822    /// Test that safe symlinked directories work with non-existent paths.
823    #[cfg(unix)]
824    #[test]
825    fn test_sandbox_join_allows_safe_symlink_dir_with_nonexistent_path() {
826        let temp = create_test_dir();
827
828        // Create a subdirectory inside the sandbox
829        let inside_dir = temp.path().join("real_subdir");
830        std::fs::create_dir(&inside_dir).unwrap();
831
832        // Create a symlink inside the sandbox pointing to the inside directory
833        let safe_link = temp.path().join("link_to_subdir");
834        std::os::unix::fs::symlink(&inside_dir, &safe_link).unwrap();
835
836        // With symlinks allowed, joining a non-existent file under a SAFE symlink should work
837        let config = SandboxConfig::permissive();
838        let root = SandboxRoot::new(temp.path(), config).unwrap();
839
840        // This should succeed - link_to_subdir resolves to inside the sandbox
841        let result = root.join("link_to_subdir/new_file.txt");
842        assert!(
843            result.is_ok(),
844            "Expected safe symlink with non-existent path to succeed"
845        );
846    }
847
848    // ========================================================================
849    // SandboxRoot::join() - hardlink handling (Unix only)
850    // ========================================================================
851
852    #[cfg(unix)]
853    #[test]
854    fn test_sandbox_join_rejects_hardlink_by_default() {
855        let temp = create_test_dir();
856        let original = temp.path().join("original.txt");
857        std::fs::write(&original, "content").unwrap();
858
859        let hardlink = temp.path().join("hardlink.txt");
860        std::fs::hard_link(&original, &hardlink).unwrap();
861
862        let root = SandboxRoot::new_default(temp.path()).unwrap();
863        let result = root.join("hardlink.txt");
864        assert!(result.is_err());
865        assert!(matches!(
866            result.unwrap_err(),
867            SandboxError::HardlinkNotAllowed { .. }
868        ));
869    }
870
871    #[cfg(unix)]
872    #[test]
873    fn test_sandbox_join_allows_hardlink_when_configured() {
874        let temp = create_test_dir();
875        let original = temp.path().join("original.txt");
876        std::fs::write(&original, "content").unwrap();
877
878        let hardlink = temp.path().join("hardlink.txt");
879        std::fs::hard_link(&original, &hardlink).unwrap();
880
881        let config = SandboxConfig::permissive();
882        let root = SandboxRoot::new(temp.path(), config).unwrap();
883        let result = root.join("hardlink.txt");
884        assert!(result.is_ok());
885    }
886
887    // ========================================================================
888    // SandboxPath tests
889    // ========================================================================
890
891    #[test]
892    fn test_sandbox_path_as_path() {
893        let temp = create_test_dir();
894        let file = temp.path().join("file.txt");
895        std::fs::write(&file, "content").unwrap();
896
897        let root = SandboxRoot::new_default(temp.path()).unwrap();
898        let sandbox_path = root.join("file.txt").unwrap();
899
900        // as_path should return the full path
901        assert!(sandbox_path.as_path().ends_with("file.txt"));
902        assert!(sandbox_path.as_path().is_absolute());
903    }
904
905    #[test]
906    fn test_sandbox_path_relative() {
907        let temp = create_test_dir();
908        let subdir = temp.path().join("a/b/c");
909        std::fs::create_dir_all(&subdir).unwrap();
910        let file = subdir.join("file.txt");
911        std::fs::write(&file, "content").unwrap();
912
913        let root = SandboxRoot::new_default(temp.path()).unwrap();
914        let sandbox_path = root.join("a/b/c/file.txt").unwrap();
915
916        // relative should return just the relative portion
917        assert_eq!(sandbox_path.relative(), Path::new("a/b/c/file.txt"));
918    }
919
920    #[test]
921    fn test_sandbox_path_to_path_buf() {
922        let temp = create_test_dir();
923        let file = temp.path().join("file.txt");
924        std::fs::write(&file, "content").unwrap();
925
926        let root = SandboxRoot::new_default(temp.path()).unwrap();
927        let sandbox_path = root.join("file.txt").unwrap();
928
929        let path_buf = sandbox_path.to_path_buf();
930        assert!(path_buf.is_absolute());
931        assert!(path_buf.ends_with("file.txt"));
932    }
933
934    #[test]
935    fn test_sandbox_path_as_ref() {
936        let temp = create_test_dir();
937        let file = temp.path().join("file.txt");
938        std::fs::write(&file, "content").unwrap();
939
940        let root = SandboxRoot::new_default(temp.path()).unwrap();
941        let sandbox_path = root.join("file.txt").unwrap();
942
943        // Test AsRef<Path> implementation
944        let path_ref: &Path = sandbox_path.as_ref();
945        assert!(path_ref.ends_with("file.txt"));
946    }
947
948    // ========================================================================
949    // SandboxConfig tests
950    // ========================================================================
951
952    #[test]
953    fn test_sandbox_config_default() {
954        let config = SandboxConfig::default();
955        assert!(!config.allow_symlinks);
956        assert!(!config.allow_hardlinks);
957    }
958
959    #[test]
960    fn test_sandbox_config_permissive() {
961        let config = SandboxConfig::permissive();
962        assert!(config.allow_symlinks);
963        assert!(config.allow_hardlinks);
964    }
965
966    // ========================================================================
967    // SandboxError tests
968    // ========================================================================
969
970    #[test]
971    fn test_sandbox_error_display() {
972        let err = SandboxError::ParentTraversal {
973            path: "../escape".to_string(),
974        };
975        let msg = err.to_string();
976        assert!(msg.contains("parent directory traversal"));
977        assert!(msg.contains("../escape"));
978    }
979
980    #[test]
981    fn test_sandbox_error_equality() {
982        let err1 = SandboxError::AbsolutePath {
983            path: "/etc/passwd".to_string(),
984        };
985        let err2 = SandboxError::AbsolutePath {
986            path: "/etc/passwd".to_string(),
987        };
988        assert_eq!(err1, err2);
989    }
990}