proc_canonicalize/
lib.rs

1//! # proc-canonicalize
2//!
3//! A patch for `std::fs::canonicalize` that preserves Linux `/proc/PID/root` and
4//! `/proc/PID/cwd` namespace boundaries.
5//!
6//! ## The Problem
7//!
8//! On Linux, `/proc/PID/root` is a "magic symlink" that crosses into a process's
9//! mount namespace. However, `std::fs::canonicalize` resolves it to `/`, losing
10//! the namespace context:
11//!
12//! ```rust
13//! # #[cfg(target_os = "linux")]
14//! # fn main() -> std::io::Result<()> {
15//! // The kernel resolves /proc/self/root to "/" - losing the namespace boundary!
16//! let resolved = std::fs::canonicalize("/proc/self/root")?;
17//! assert_eq!(resolved, std::path::PathBuf::from("/"));
18//! # Ok(())
19//! # }
20//! # #[cfg(not(target_os = "linux"))]
21//! # fn main() {}
22//! ```
23//!
24//! This breaks security tools that use `/proc/PID/root` as a boundary for container
25//! filesystem access, because the boundary resolves to the host root!
26//!
27//! ## The Fix
28//!
29//! This crate detects `/proc/PID/root` and `/proc/PID/cwd` prefixes and preserves them:
30//!
31//! ```rust
32//! # #[cfg(target_os = "linux")]
33//! # fn main() -> std::io::Result<()> {
34//! use std::path::PathBuf;
35//!
36//! // The namespace boundary is preserved!
37//! let resolved = proc_canonicalize::canonicalize("/proc/self/root")?;
38//! assert_eq!(resolved, PathBuf::from("/proc/self/root"));
39//!
40//! // Paths through the boundary also preserve the prefix
41//! let resolved = proc_canonicalize::canonicalize("/proc/self/root/etc")?;
42//! assert!(resolved.starts_with("/proc/self/root"));
43//! # Ok(())
44//! # }
45//! # #[cfg(not(target_os = "linux"))]
46//! # fn main() {}
47//! ```
48//!
49//! ## Platform Support
50//!
51//! - **Linux**: Full functionality - preserves `/proc/PID/root` and `/proc/PID/cwd`
52//! - **Other platforms**: Falls back to `std::fs::canonicalize` (no-op)
53//!
54//! ## Zero Dependencies
55//!
56//! This crate has no dependencies beyond the Rust standard library.
57//!
58//! ## Optional Features
59//!
60//! - `dunce` (Windows only): Simplifies Windows extended-length paths by removing the `\\?\` prefix
61//!   when possible (e.g., `\\?\C:\foo` becomes `C:\foo`). Automatically preserves the prefix when
62//!   needed (e.g., for paths longer than 260 characters). Enable with `features = ["dunce"]`.
63
64#![forbid(unsafe_code)]
65#![warn(missing_docs)]
66
67use std::io;
68use std::path::{Path, PathBuf};
69
70#[cfg(target_os = "linux")]
71use std::path::Component;
72
73/// Maximum number of symlinks to follow before giving up (matches kernel MAXSYMLINKS).
74#[cfg(target_os = "linux")]
75const MAX_SYMLINK_FOLLOWS: u32 = 40;
76
77/// Canonicalize a path, preserving Linux `/proc/PID/root` and `/proc/PID/cwd` boundaries.
78///
79/// This function behaves like [`std::fs::canonicalize`], except that on Linux it
80/// detects and preserves namespace boundary prefixes:
81/// - `/proc/PID/root`, `/proc/PID/cwd`
82/// - `/proc/PID/task/TID/root`, `/proc/PID/task/TID/cwd`
83/// - `/proc/self/root`, `/proc/self/cwd`
84/// - `/proc/thread-self/root`, `/proc/thread-self/cwd`
85///
86/// # Examples
87///
88/// ```rust
89/// # #[cfg(target_os = "linux")]
90/// # fn main() -> std::io::Result<()> {
91/// use std::path::PathBuf;
92/// use proc_canonicalize::canonicalize;
93///
94/// // On Linux, the namespace prefix is preserved
95/// let path = "/proc/self/root";
96/// let canonical = canonicalize(path)?;
97/// assert_eq!(canonical, PathBuf::from("/proc/self/root"));
98/// # Ok(())
99/// # }
100/// # #[cfg(not(target_os = "linux"))]
101/// # fn main() {}
102/// ```
103///
104/// # Why This Matters
105///
106/// `std::fs::canonicalize("/proc/1234/root")` returns `/` because the kernel's
107/// `readlink()` on that magic symlink returns `/`. This breaks security boundaries
108/// for container tooling that needs to access container filesystems via `/proc/PID/root`.
109///
110/// # Platform Behavior
111///
112/// - **Linux**: Preserves `/proc/PID/root` and `/proc/PID/cwd` prefixes
113/// - **Other platforms**: Identical to `std::fs::canonicalize`
114///
115/// # Errors
116///
117/// Returns an error if:
118/// - The path does not exist
119/// - The process lacks permission to access the path
120/// - An I/O error occurs during resolution
121pub fn canonicalize(path: impl AsRef<Path>) -> io::Result<PathBuf> {
122    canonicalize_impl(path.as_ref())
123}
124
125#[cfg(target_os = "linux")]
126fn canonicalize_impl(path: &Path) -> io::Result<PathBuf> {
127    // Check if path contains a /proc namespace boundary
128    if let Some((namespace_prefix, remainder)) = find_namespace_boundary(path) {
129        // Verify the namespace prefix exists and is accessible
130        // We use metadata() to check existence and permissions, which gives better error messages
131        // than exists() (e.g. PermissionDenied vs NotFound)
132        std::fs::metadata(&namespace_prefix)?;
133
134        if remainder.as_os_str().is_empty() {
135            // Path IS the namespace boundary (e.g., "/proc/1234/root")
136            Ok(namespace_prefix)
137        } else {
138            // Path goes through namespace boundary (e.g., "/proc/1234/root/etc/passwd")
139
140            // 1. Resolve the namespace prefix to its absolute path on the host.
141            // This is necessary because /proc/PID/root might not be "/" (e.g. in containers),
142            // and /proc/PID/cwd is almost certainly not "/".
143            let resolved_prefix = std::fs::canonicalize(&namespace_prefix)?;
144
145            // 2. Canonicalize the full path.
146            // This traverses the magic link and resolves everything.
147            let full_path = namespace_prefix.join(&remainder);
148            let canonicalized = std::fs::canonicalize(full_path)?;
149
150            // 3. Try to re-base the canonicalized path onto the namespace prefix.
151            // We do this by stripping the resolved prefix from the canonicalized path.
152            if let Ok(suffix) = canonicalized.strip_prefix(&resolved_prefix) {
153                // The path is within the namespace. Re-attach the prefix.
154                Ok(namespace_prefix.join(suffix))
155            } else {
156                // The path escaped the namespace (e.g. via ".." or symlinks to outside).
157                // In this case, we cannot preserve the prefix while being correct.
158                // We return the fully resolved path (absolute path on host).
159                Ok(canonicalized)
160            }
161        }
162    } else {
163        // Check for indirect symlinks to /proc magic paths BEFORE calling std::fs::canonicalize.
164        //
165        // This handles cases like:
166        //   symlink("/proc/self/root", "/tmp/container_link")
167        //   canonicalize("/tmp/container_link")        -> should return /proc/self/root, not /
168        //   canonicalize("/tmp/container_link/etc")    -> should return /proc/self/root/etc, not /etc
169        //
170        // We detect symlinks in the path that point to /proc magic paths and handle them
171        // the same way we handle direct /proc paths.
172        if let Some(magic_path) = detect_indirect_proc_magic_link(path)? {
173            // Found an indirect symlink to a /proc magic path
174            // Use our namespace-aware canonicalization on the reconstructed path
175            return canonicalize_impl(&magic_path);
176        }
177
178        // Normal path - use std::fs::canonicalize directly
179        std::fs::canonicalize(path)
180    }
181}
182
183#[cfg(not(target_os = "linux"))]
184fn canonicalize_impl(path: &Path) -> io::Result<PathBuf> {
185    // On non-Linux platforms, just use std::fs::canonicalize
186    #[cfg(all(feature = "dunce", windows))]
187    {
188        dunce::canonicalize(path)
189    }
190    #[cfg(not(all(feature = "dunce", windows)))]
191    {
192        std::fs::canonicalize(path)
193    }
194}
195
196/// Find a `/proc/PID/root` or `/proc/PID/cwd` namespace boundary in the path.
197///
198/// Returns `Some((namespace_prefix, remainder))` if found, where:
199/// - `namespace_prefix` is the boundary path (e.g., `/proc/1234/root`)
200/// - `remainder` is the path after the boundary (e.g., `etc/passwd`)
201///
202/// Returns `None` if the path doesn't contain a namespace boundary.
203#[cfg(target_os = "linux")]
204fn find_namespace_boundary(path: &Path) -> Option<(PathBuf, PathBuf)> {
205    let mut components = path.components();
206
207    // Must start with root "/"
208    if components.next() != Some(Component::RootDir) {
209        return None;
210    }
211
212    // Next must be "proc"
213    match components.next() {
214        Some(Component::Normal(s)) if s == "proc" => {}
215        _ => return None,
216    }
217
218    // Next must be a PID (digits), "self", or "thread-self"
219    let pid_component = match components.next() {
220        Some(Component::Normal(s)) => s,
221        _ => return None,
222    };
223
224    let pid_str = pid_component.to_string_lossy();
225    let is_valid_pid = pid_str == "self"
226        || pid_str == "thread-self"
227        || (!pid_str.is_empty() && pid_str.chars().all(|c| c.is_ascii_digit()));
228
229    if !is_valid_pid {
230        return None;
231    }
232
233    // Next component determines if it's a direct namespace or a task namespace
234    let next_component = match components.next() {
235        Some(Component::Normal(s)) => s,
236        _ => return None,
237    };
238
239    if next_component == "root" || next_component == "cwd" {
240        // /proc/PID/root or /proc/PID/cwd
241        let mut prefix = PathBuf::from("/proc");
242        prefix.push(pid_component);
243        prefix.push(next_component);
244
245        // Collect remaining components as the remainder
246        let remainder: PathBuf = components.collect();
247        Some((prefix, remainder))
248    } else if next_component == "task" {
249        // /proc/PID/task/TID/root or /proc/PID/task/TID/cwd
250
251        // Next must be TID (digits)
252        let tid_component = match components.next() {
253            Some(Component::Normal(s)) => s,
254            _ => return None,
255        };
256
257        let tid_str = tid_component.to_string_lossy();
258        if tid_str.is_empty() || !tid_str.chars().all(|c| c.is_ascii_digit()) {
259            return None;
260        }
261
262        // Next must be root or cwd
263        let ns_type = match components.next() {
264            Some(Component::Normal(s)) if s == "root" || s == "cwd" => s,
265            _ => return None,
266        };
267
268        let mut prefix = PathBuf::from("/proc");
269        prefix.push(pid_component);
270        prefix.push("task");
271        prefix.push(tid_component);
272        prefix.push(ns_type);
273
274        // Collect remaining components as the remainder
275        let remainder: PathBuf = components.collect();
276        Some((prefix, remainder))
277    } else {
278        None
279    }
280}
281
282/// Check if a path is a `/proc` magic path (`/proc/{pid}/root` or `/proc/{pid}/cwd`).
283///
284/// This checks whether the path matches patterns like:
285/// - `/proc/self/root`, `/proc/self/cwd`
286/// - `/proc/thread-self/root`, `/proc/thread-self/cwd`
287/// - `/proc/{numeric_pid}/root`, `/proc/{numeric_pid}/cwd`
288///
289/// The path may have additional components after the magic suffix (e.g., `/proc/self/root/etc`).
290#[cfg(target_os = "linux")]
291fn is_proc_magic_path(path: &Path) -> bool {
292    find_namespace_boundary(path).is_some()
293}
294
295/// Detect if a path contains an indirect symlink to a `/proc` magic path.
296///
297/// This walks the ancestor chain of the input path looking for symlinks that
298/// point to `/proc/.../root` or `/proc/.../cwd`.
299///
300/// Returns `Some(magic_path)` with any remaining suffix if found, or `None` otherwise.
301#[cfg(target_os = "linux")]
302fn detect_indirect_proc_magic_link(path: &Path) -> io::Result<Option<PathBuf>> {
303    let mut current_path = if path.is_absolute() {
304        path.to_path_buf()
305    } else {
306        std::env::current_dir()?.join(path)
307    };
308
309    let mut iterations = 0;
310
311    // We restart the scan whenever we resolve a symlink
312    'scan: loop {
313        if iterations >= MAX_SYMLINK_FOLLOWS {
314            return Ok(None);
315        }
316
317        // We CANNOT blindly normalize_path() here because if we have "symlink/..",
318        // normalize_path() will remove "symlink" and "..", completely missing the fact
319        // that "symlink" might point to a magic path.
320        //
321        // Instead, we must walk the components one by one. If we hit a symlink, we resolve it.
322        // If we hit "..", we pop from our accumulated path.
323
324        // Check if the path ITSELF is magic (e.g. after resolution)
325        // We still check this first because we might have just resolved a symlink to a magic path
326        if is_proc_magic_path(&current_path) {
327            return Ok(Some(current_path));
328        }
329
330        let mut accumulated = PathBuf::new();
331        let mut components = current_path.components().peekable();
332
333        if let Some(Component::RootDir) = components.peek() {
334            accumulated.push("/");
335            components.next();
336        }
337
338        while let Some(component) = components.next() {
339            match component {
340                Component::RootDir => {
341                    accumulated.push("/");
342                }
343                Component::CurDir => {}
344                Component::ParentDir => {
345                    accumulated.pop();
346                    // After popping, we might be at a magic path (e.g. /proc/self/root/etc/..)
347                    if is_proc_magic_path(&accumulated) {
348                        // Reconstruct full path from here to preserve the magic prefix
349                        let remainder: PathBuf = components.collect();
350                        return Ok(Some(accumulated.join(remainder)));
351                    }
352                }
353                Component::Normal(name) => {
354                    let next_path = accumulated.join(name);
355
356                    // Check symlink
357                    let metadata = match std::fs::symlink_metadata(&next_path) {
358                        Ok(m) => m,
359                        Err(_) => {
360                            accumulated.push(name);
361                            continue;
362                        }
363                    };
364
365                    if metadata.is_symlink() {
366                        // Found symlink!
367                        iterations += 1;
368                        let target = std::fs::read_link(&next_path)?;
369
370                        // Construct new path: accumulated (parent) + target + remainder
371                        let parent = next_path.parent().unwrap_or(Path::new("/"));
372                        let remainder: PathBuf = components.collect();
373
374                        let resolved = if target.is_relative() {
375                            parent.join(target)
376                        } else {
377                            target
378                        };
379
380                        current_path = resolved.join(remainder);
381                        continue 'scan; // Restart scan from root of new path
382                    }
383
384                    accumulated.push(name);
385                }
386                Component::Prefix(_) => unreachable!("Linux paths don't have prefixes"),
387            }
388        }
389
390        // If we reached here, we scanned the whole path and found no symlinks (or no more symlinks).
391        // And it wasn't magic (checked at start of loop).
392        // One final check on the accumulated path (which is effectively normalized now)
393        if is_proc_magic_path(&accumulated) {
394            return Ok(Some(accumulated));
395        }
396
397        return Ok(None);
398    }
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    #[cfg(target_os = "linux")]
406    mod linux {
407        use super::*;
408
409        // ==========================================================================
410        // NAMESPACE BOUNDARY DETECTION (find_namespace_boundary)
411        // These tests verify the lexical pattern matching that identifies
412        // /proc/PID/root and /proc/PID/cwd as namespace boundaries.
413        // ==========================================================================
414
415        #[test]
416        fn test_find_namespace_boundary_proc_pid_root() {
417            // Standard pattern: /proc/<numeric_pid>/root
418            // Used by container runtimes to access container filesystems from host
419            let (prefix, remainder) =
420                find_namespace_boundary(Path::new("/proc/1234/root/etc/passwd")).unwrap();
421            assert_eq!(prefix, PathBuf::from("/proc/1234/root"));
422            assert_eq!(remainder, PathBuf::from("etc/passwd"));
423        }
424
425        #[test]
426        fn test_find_namespace_boundary_proc_pid_cwd() {
427            // Pattern: /proc/<pid>/cwd - the process's current working directory
428            // Less common but equally needs protection
429            let (prefix, remainder) =
430                find_namespace_boundary(Path::new("/proc/5678/cwd/some/file.txt")).unwrap();
431            assert_eq!(prefix, PathBuf::from("/proc/5678/cwd"));
432            assert_eq!(remainder, PathBuf::from("some/file.txt"));
433        }
434
435        #[test]
436        fn test_find_namespace_boundary_proc_self_root() {
437            // /proc/self/root - own process's root, resolves to "/" on host
438            // Common in self-referential container tooling
439            let (prefix, remainder) =
440                find_namespace_boundary(Path::new("/proc/self/root/etc/passwd")).unwrap();
441            assert_eq!(prefix, PathBuf::from("/proc/self/root"));
442            assert_eq!(remainder, PathBuf::from("etc/passwd"));
443        }
444
445        #[test]
446        fn test_find_namespace_boundary_proc_thread_self_root() {
447            // /proc/thread-self/root - per-thread namespace, less common
448            let (prefix, remainder) =
449                find_namespace_boundary(Path::new("/proc/thread-self/root/app/config")).unwrap();
450            assert_eq!(prefix, PathBuf::from("/proc/thread-self/root"));
451            assert_eq!(remainder, PathBuf::from("app/config"));
452        }
453
454        #[test]
455        fn test_find_namespace_boundary_just_prefix_no_remainder() {
456            // Accessing just the magic path itself, no subpath
457            let (prefix, remainder) =
458                find_namespace_boundary(Path::new("/proc/1234/root")).unwrap();
459            assert_eq!(prefix, PathBuf::from("/proc/1234/root"));
460            assert_eq!(remainder, PathBuf::from(""));
461        }
462
463        #[test]
464        fn test_find_namespace_boundary_normal_path_returns_none() {
465            // Regular paths should NOT match - no namespace treatment needed
466            assert!(find_namespace_boundary(Path::new("/home/user/file.txt")).is_none());
467        }
468
469        #[test]
470        fn test_find_namespace_boundary_proc_other_files_not_namespace() {
471            // SECURITY: /proc/PID/status, /proc/PID/exe, /proc/PID/fd are NOT namespaces
472            // Only "root" and "cwd" are magic symlinks that cross namespace boundaries
473            assert!(find_namespace_boundary(Path::new("/proc/1234/status")).is_none());
474            assert!(find_namespace_boundary(Path::new("/proc/1234/exe")).is_none());
475            assert!(find_namespace_boundary(Path::new("/proc/1234/fd/0")).is_none());
476        }
477
478        #[test]
479        fn test_find_namespace_boundary_relative_path_rejected() {
480            // SECURITY: Only absolute paths can be namespace boundaries
481            // "proc/1234/root" without leading "/" is relative, not /proc
482            assert!(find_namespace_boundary(Path::new("proc/1234/root")).is_none());
483        }
484
485        #[test]
486        fn test_find_namespace_boundary_invalid_pid_rejected() {
487            // SECURITY: PID must be numeric, "self", or "thread-self"
488            // Arbitrary strings like "abc" must not match
489            assert!(find_namespace_boundary(Path::new("/proc/abc/root")).is_none());
490            assert!(find_namespace_boundary(Path::new("/proc/123abc/root")).is_none());
491            assert!(find_namespace_boundary(Path::new("/proc//root")).is_none());
492        }
493
494        // ==========================================================================
495        // USAGE EXAMPLES: How to use this crate for container monitoring
496        // ==========================================================================
497
498        #[test]
499        fn reading_container_file_from_host() {
500            // Real-world pattern: Host process reads a container's /etc/hostname
501            let container_pid = std::process::id(); // In reality, this would be a container's PID
502            let container_root = format!("/proc/{}/root", container_pid);
503            let file_inside_container = format!("{}/etc", container_root);
504
505            let canonical_path = canonicalize(file_inside_container).unwrap();
506
507            // The path STAYS inside the container namespace
508            assert!(canonical_path.starts_with(&container_root));
509        }
510
511        #[test]
512        fn validating_path_stays_in_container() {
513            // Security pattern: Verify a user-provided path doesn't escape container
514            let container_pid = std::process::id();
515            let container_root = format!("/proc/{}/root", container_pid);
516            let user_requested_file = format!("{}/etc/passwd", container_root);
517
518            let canonical = canonicalize(user_requested_file).unwrap();
519
520            // Security check: canonical path must start with container_root
521            let is_inside_container = canonical.starts_with(&container_root);
522            assert!(is_inside_container);
523        }
524
525        #[test]
526        fn proc_self_root_preserved_not_resolved_to_slash() {
527            let path = "/proc/self/root";
528
529            let our_result = canonicalize(path).unwrap();
530            let std_result = std::fs::canonicalize(path).unwrap();
531
532            // std breaks it: returns "/"
533            assert_eq!(std_result, PathBuf::from("/"));
534
535            // we fix it: preserves the namespace
536            assert_eq!(our_result, PathBuf::from("/proc/self/root"));
537        }
538
539        #[test]
540        fn proc_self_cwd_preserved() {
541            let path = "/proc/self/cwd";
542
543            let result = canonicalize(path).unwrap();
544
545            assert_eq!(result, PathBuf::from("/proc/self/cwd"));
546        }
547
548        #[test]
549        fn explicit_pid_root_preserved() {
550            let my_pid = std::process::id();
551            let path = format!("/proc/{}/root", my_pid);
552
553            let our_result = canonicalize(&path).unwrap();
554            let std_result = std::fs::canonicalize(&path).unwrap();
555
556            assert_eq!(std_result, PathBuf::from("/"));
557            assert_eq!(our_result, PathBuf::from(&path));
558        }
559
560        #[test]
561        fn subpath_through_namespace_preserves_prefix() {
562            let path = "/proc/self/root/etc";
563
564            let result = canonicalize(path).unwrap();
565
566            assert!(result.starts_with("/proc/self/root"));
567            assert!(result.ends_with("etc"));
568        }
569
570        #[test]
571        fn normal_paths_behave_like_std() {
572            let path = std::env::temp_dir();
573
574            let our_result = canonicalize(&path).unwrap();
575            let std_result = std::fs::canonicalize(&path).unwrap();
576
577            assert_eq!(our_result, std_result);
578        }
579
580        // ==========================================================================
581        // ERROR CASES: What happens with invalid input
582        // ==========================================================================
583
584        #[test]
585        fn nonexistent_file_returns_not_found() {
586            let path = "/proc/self/root/this_file_does_not_exist_12345";
587
588            let result = canonicalize(path);
589
590            assert!(result.is_err());
591            assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound);
592        }
593
594        #[test]
595        fn nonexistent_pid_returns_not_found() {
596            let path = "/proc/4294967295/root"; // PID that doesn't exist
597
598            let result = canonicalize(path);
599
600            assert!(result.is_err());
601            assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound);
602        }
603
604        #[test]
605        fn empty_path_returns_error() {
606            let result = canonicalize("");
607
608            assert!(result.is_err());
609        }
610
611        // ==========================================================================
612        // PATH NORMALIZATION: Dots and parent references
613        // ==========================================================================
614
615        #[test]
616        fn dotdot_stays_inside_root_namespace() {
617            let path = "/proc/self/root/tmp/../etc";
618
619            let result = canonicalize(path);
620
621            if let Ok(canonical) = result {
622                assert!(canonical.starts_with("/proc/self/root"));
623            }
624        }
625
626        #[test]
627        fn dot_is_normalized_out() {
628            let path = "/proc/self/root/./etc";
629
630            let result = canonicalize(path);
631
632            if let Ok(canonical) = result {
633                assert!(canonical.starts_with("/proc/self/root"));
634                assert!(!canonical.to_string_lossy().contains("/./"));
635            }
636        }
637
638        #[test]
639        fn deep_path_preserves_namespace() {
640            let path = "/proc/self/root/usr/share/doc";
641
642            let result = canonicalize(path);
643
644            if let Ok(canonical) = result {
645                assert!(canonical.starts_with("/proc/self/root"));
646            }
647        }
648
649        #[test]
650        fn trailing_slash_works() {
651            let with_slash = canonicalize("/proc/self/root/");
652            let without_slash = canonicalize("/proc/self/root");
653
654            if let (Ok(a), Ok(b)) = (with_slash, without_slash) {
655                assert!(a.starts_with("/proc/self/root"));
656                assert!(b.starts_with("/proc/self/root"));
657            }
658        }
659
660        #[test]
661        fn thread_self_root_preserved() {
662            let path = "/proc/thread-self/root";
663
664            if let Ok(result) = canonicalize(path) {
665                assert_eq!(result, PathBuf::from("/proc/thread-self/root"));
666            }
667        }
668
669        // ==========================================================================
670        // EDGE CASES FOR BOUNDARY DETECTION
671        // ==========================================================================
672
673        #[test]
674        fn boundary_detection_handles_trailing_slash() {
675            let (prefix, _remainder) =
676                find_namespace_boundary(Path::new("/proc/1234/root/")).unwrap();
677            assert_eq!(prefix, PathBuf::from("/proc/1234/root"));
678        }
679
680        #[test]
681        fn boundary_detection_handles_dot_components() {
682            let (prefix, _remainder) =
683                find_namespace_boundary(Path::new("/proc/1234/root/./etc/../etc")).unwrap();
684            assert_eq!(prefix, PathBuf::from("/proc/1234/root"));
685        }
686
687        // ==========================================================================
688        // ACCESSING OTHER PROCESSES (requires permissions)
689        // ==========================================================================
690
691        #[test]
692        fn pid_1_root_requires_permission_or_preserves_prefix() {
693            let path = "/proc/1/root";
694
695            match canonicalize(path) {
696                Ok(result) => {
697                    // If accessible, prefix must be preserved
698                    assert_eq!(result, PathBuf::from("/proc/1/root"));
699                    // And std would have broken it
700                    assert_eq!(std::fs::canonicalize(path).unwrap(), PathBuf::from("/"));
701                }
702                Err(e) => {
703                    // Permission denied or not found is acceptable
704                    assert!(matches!(
705                        e.kind(),
706                        io::ErrorKind::PermissionDenied | io::ErrorKind::NotFound
707                    ));
708                }
709            }
710        }
711
712        #[test]
713        fn pid_1_subpath_preserves_prefix_when_accessible() {
714            let path = "/proc/1/root/etc/hostname";
715
716            match canonicalize(path) {
717                Ok(result) => {
718                    assert!(
719                        result.starts_with("/proc/1/root"),
720                        "must preserve /proc/1/root prefix, got: {:?}",
721                        result
722                    );
723                }
724                Err(e) => {
725                    assert!(matches!(
726                        e.kind(),
727                        io::ErrorKind::PermissionDenied | io::ErrorKind::NotFound
728                    ));
729                }
730            }
731        }
732
733        #[test]
734        fn pid_1_cwd_preserves_prefix_when_accessible() {
735            let path = "/proc/1/cwd";
736
737            match canonicalize(path) {
738                Ok(result) => assert_eq!(result, PathBuf::from("/proc/1/cwd")),
739                Err(e) => {
740                    assert!(matches!(
741                        e.kind(),
742                        io::ErrorKind::PermissionDenied | io::ErrorKind::NotFound
743                    ));
744                }
745            }
746        }
747
748        #[test]
749        fn self_and_explicit_pid_both_work() {
750            let my_pid = std::process::id();
751
752            let self_result = canonicalize("/proc/self/root").unwrap();
753            let pid_result = canonicalize(format!("/proc/{}/root", my_pid)).unwrap();
754
755            assert_eq!(self_result, PathBuf::from("/proc/self/root"));
756            assert_eq!(pid_result, PathBuf::from(format!("/proc/{}/root", my_pid)));
757        }
758
759        // ==========================================================================
760        // INDIRECT SYMLINKS: Symlinks outside /proc pointing TO /proc magic paths
761        // ==========================================================================
762
763        mod indirect_symlink_tests {
764            use super::*;
765            use std::os::unix::fs::symlink;
766
767            #[test]
768            fn symlink_to_proc_self_root_preserves_namespace() {
769                let temp = tempfile::tempdir().unwrap();
770                let link = temp.path().join("link");
771
772                symlink("/proc/self/root", &link).unwrap();
773
774                let result = canonicalize(&link).unwrap();
775
776                assert_ne!(result, PathBuf::from("/")); // NOT the broken behavior
777                assert_eq!(result, PathBuf::from("/proc/self/root"));
778            }
779
780            #[test]
781            fn symlink_then_subpath_preserves_namespace() {
782                let temp = tempfile::tempdir().unwrap();
783                let link = temp.path().join("container");
784
785                symlink("/proc/self/root", &link).unwrap();
786
787                let result = canonicalize(link.join("etc")).unwrap();
788
789                assert!(result.starts_with("/proc/self/root"));
790            }
791
792            #[test]
793            fn chained_symlinks_all_followed() {
794                let temp = tempfile::tempdir().unwrap();
795                let link1 = temp.path().join("link1");
796                let link2 = temp.path().join("link2");
797
798                symlink("/proc/self/root", &link2).unwrap();
799                symlink(&link2, &link1).unwrap();
800
801                let result = canonicalize(&link1).unwrap();
802
803                assert_eq!(result, PathBuf::from("/proc/self/root"));
804            }
805
806            #[test]
807            fn symlink_to_explicit_pid_root_preserved() {
808                let my_pid = std::process::id();
809                let target = format!("/proc/{}/root", my_pid);
810                let temp = tempfile::tempdir().unwrap();
811                let link = temp.path().join("link");
812
813                symlink(&target, &link).unwrap();
814
815                let result = canonicalize(&link).unwrap();
816
817                assert_ne!(result, PathBuf::from("/"));
818                assert_eq!(result, PathBuf::from(&target));
819            }
820
821            #[test]
822            fn symlink_to_cwd_preserved() {
823                let temp = tempfile::tempdir().unwrap();
824                let link = temp.path().join("link");
825
826                symlink("/proc/self/cwd", &link).unwrap();
827
828                let result = canonicalize(&link).unwrap();
829
830                assert!(result.starts_with("/proc/self/cwd"));
831            }
832
833            #[test]
834            fn normal_symlinks_work_like_std() {
835                let temp = tempfile::tempdir().unwrap();
836                let target = temp.path().join("target");
837                let link = temp.path().join("link");
838
839                std::fs::create_dir(&target).unwrap();
840                symlink(&target, &link).unwrap();
841
842                let our_result = canonicalize(&link).unwrap();
843                let std_result = std::fs::canonicalize(&link).unwrap();
844
845                assert_eq!(our_result, std_result);
846            }
847
848            #[test]
849            fn symlink_loop_returns_error_not_hang() {
850                let temp = tempfile::tempdir().unwrap();
851                let link_a = temp.path().join("a");
852                let link_b = temp.path().join("b");
853
854                symlink(&link_b, &link_a).unwrap();
855                symlink(&link_a, &link_b).unwrap();
856
857                let result = canonicalize(&link_a);
858
859                assert!(result.is_err());
860            }
861
862            #[test]
863            fn symlink_to_thread_self_root_preserved() {
864                let temp = tempfile::tempdir().unwrap();
865                let link = temp.path().join("thread_link");
866
867                symlink("/proc/thread-self/root", &link).unwrap();
868
869                // thread-self might not exist on all systems
870                if let Ok(result) = canonicalize(&link) {
871                    assert!(result.starts_with("/proc/thread-self/root"));
872                }
873            }
874        }
875
876        // ==========================================================================
877        // SECURITY EDGE CASES
878        // ==========================================================================
879
880        mod security_tests {
881            use super::*;
882
883            #[test]
884            fn excessive_dotdot_cannot_escape_root_namespace() {
885                let path = "/proc/self/root/../../../../../../../etc/passwd";
886
887                if let Ok(result) = canonicalize(path) {
888                    assert!(result.starts_with("/proc/self/root"));
889                }
890            }
891
892            #[test]
893            fn idempotent_canonicalization() {
894                let paths = ["/proc/self/root", "/proc/self/root/etc", "/proc/self/cwd"];
895
896                for path in &paths {
897                    if let Ok(first) = canonicalize(path) {
898                        if let Ok(second) = canonicalize(&first) {
899                            assert_eq!(first, second);
900                        }
901                    }
902                }
903            }
904
905            #[test]
906            fn uppercase_proc_not_magic() {
907                let result = canonicalize("/PROC/self/root");
908
909                match result {
910                    Ok(path) => assert!(!path.starts_with("/proc/")),
911                    Err(e) => assert_eq!(e.kind(), io::ErrorKind::NotFound),
912                }
913            }
914
915            #[test]
916            fn double_slashes_normalized() {
917                if let Ok(normal) = canonicalize("/proc/self/root") {
918                    if let Ok(doubled) = canonicalize("//proc//self//root") {
919                        assert_eq!(normal, doubled);
920                    }
921                }
922            }
923
924            #[test]
925            fn relative_proc_path_not_magic() {
926                // "proc/self/root" (no leading /) is relative, not magic
927                let _ = canonicalize("proc/self/root"); // Just shouldn't panic
928            }
929
930            #[test]
931            fn missing_pid_not_namespace() {
932                let result = find_namespace_boundary(Path::new("/proc/root"));
933                assert!(result.is_none());
934            }
935
936            #[test]
937            fn invalid_special_names_not_namespace() {
938                for name in &["parent", "init", "current", "me"] {
939                    let path = format!("/proc/{}/root", name);
940                    assert!(find_namespace_boundary(Path::new(&path)).is_none());
941                }
942            }
943
944            #[test]
945            fn long_numeric_pid_accepted() {
946                let long_pid = "9".repeat(100);
947                let path = format!("/proc/{}/root", long_pid);
948                assert!(find_namespace_boundary(Path::new(&path)).is_some());
949            }
950
951            #[test]
952            fn pid_zero_syntactically_valid() {
953                assert!(find_namespace_boundary(Path::new("/proc/0/root")).is_some());
954                assert!(canonicalize("/proc/0/root").is_err()); // But doesn't exist
955            }
956
957            #[test]
958            fn negative_pid_not_valid() {
959                assert!(find_namespace_boundary(Path::new("/proc/-1/root")).is_none());
960            }
961
962            #[test]
963            fn leading_zeros_in_pid_accepted() {
964                assert!(find_namespace_boundary(Path::new("/proc/0001234/root")).is_some());
965            }
966
967            #[test]
968            fn symlink_to_deep_proc_path_preserves_prefix() {
969                use std::os::unix::fs::symlink;
970
971                let temp = tempfile::tempdir().unwrap();
972                let link = temp.path().join("link");
973
974                symlink("/proc/self/root/etc", &link).unwrap();
975
976                if let Ok(result) = canonicalize(&link) {
977                    assert!(result.starts_with("/proc/self/root"));
978                }
979            }
980
981            #[test]
982            fn relative_symlink_looking_like_proc_not_magic() {
983                use std::os::unix::fs::symlink;
984
985                let temp = tempfile::tempdir().unwrap();
986                let fake_proc = temp.path().join("proc/self/root");
987                std::fs::create_dir_all(fake_proc).unwrap();
988
989                let link = temp.path().join("link");
990                symlink("proc/self/root", &link).unwrap();
991
992                let result = canonicalize(&link).unwrap();
993
994                assert!(!result.starts_with("/proc/self/root"));
995                assert!(result.starts_with(temp.path()));
996            }
997
998            #[test]
999            fn relative_symlink_escape_behaves_like_std() {
1000                // Normal symlink (not to /proc) that attempts path traversal escape
1001                // Must behave exactly like std::fs::canonicalize
1002                use std::os::unix::fs::symlink;
1003
1004                let temp = tempfile::tempdir().unwrap();
1005                let subdir = temp.path().join("subdir");
1006                std::fs::create_dir(&subdir).unwrap();
1007
1008                let escape_link = subdir.join("escape");
1009                symlink("../../../../../../etc", &escape_link).unwrap();
1010
1011                let our_result = canonicalize(&escape_link);
1012                let std_result = std::fs::canonicalize(&escape_link);
1013
1014                match (our_result, std_result) {
1015                    (Ok(ours), Ok(stds)) => assert_eq!(ours, stds),
1016                    (Err(_), Err(_)) => {} // Both error is fine
1017                    _ => panic!("Behavior should match std"),
1018                }
1019            }
1020        }
1021    }
1022
1023    #[cfg(not(target_os = "linux"))]
1024    mod non_linux {
1025        use super::*;
1026
1027        #[test]
1028        fn test_canonicalize_is_std_on_non_linux() {
1029            // On non-Linux, we just wrap std::fs::canonicalize
1030            let tmp = std::env::temp_dir();
1031            let our_result = canonicalize(&tmp).expect("should succeed");
1032            let std_result = std::fs::canonicalize(&tmp).expect("should succeed");
1033            // With dunce feature on Windows, our result is simplified but std returns UNC
1034            #[cfg(all(feature = "dunce", windows))]
1035            {
1036                let our_str = our_result.to_string_lossy();
1037                let std_str = std_result.to_string_lossy();
1038                // dunce should simplify the path
1039                assert!(!our_str.starts_with(r"\\?\"), "dunce should simplify path");
1040                assert!(std_str.starts_with(r"\\?\"), "std returns UNC format");
1041                // They should match except for the UNC prefix
1042                assert_eq!(our_str.as_ref(), std_str.trim_start_matches(r"\\?\"));
1043            }
1044            // Without dunce (or on non-Windows), they should match exactly
1045            #[cfg(not(all(feature = "dunce", windows)))]
1046            {
1047                assert_eq!(our_result, std_result);
1048            }
1049        }
1050    }
1051}