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