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//! For all other paths, behavior is identical to `std::fs::canonicalize`:
50//!
51//! ```rust
52//! # fn main() -> std::io::Result<()> {
53//! // Normal paths behave exactly like std::fs::canonicalize
54//! let std_result = std::fs::canonicalize(".")?;
55//! let our_result = proc_canonicalize::canonicalize(".")?;
56//! // Note: On Windows with the `dunce` feature, our result may differ
57//! // (simplified path without \\?\ prefix). See unit tests for full coverage.
58//! #[cfg(not(windows))]
59//! assert_eq!(std_result, our_result);
60//! #[cfg(windows)]
61//! let _ = (std_result, our_result); // Use variables to avoid warnings
62//! # Ok(())
63//! # }
64//! ```
65//!
66//! ## Platform Support
67//!
68//! - **Linux**: Full functionality - preserves `/proc/PID/root` and `/proc/PID/cwd`
69//! - **Other platforms**: Falls back to `std::fs::canonicalize` (no-op)
70//!
71//! ## Zero Dependencies
72//!
73//! This crate has no dependencies beyond the Rust standard library.
74//!
75//! ## Optional Features
76//!
77//! - `dunce` (Windows only): Simplifies Windows extended-length paths by removing the `\\?\` prefix
78//!   when possible (e.g., `\\?\C:\foo` becomes `C:\foo`). Automatically preserves the prefix when
79//!   needed (e.g., for paths longer than 260 characters). Enable with `features = ["dunce"]`.
80
81#![forbid(unsafe_code)]
82#![warn(missing_docs)]
83
84use std::io;
85use std::path::{Path, PathBuf};
86
87#[cfg(target_os = "linux")]
88use std::path::Component;
89
90/// Maximum number of symlinks to follow before giving up (matches kernel MAXSYMLINKS).
91#[cfg(target_os = "linux")]
92const MAX_SYMLINK_FOLLOWS: u32 = 40;
93
94/// Canonicalize a path, preserving Linux `/proc/PID/root` and `/proc/PID/cwd` boundaries.
95///
96/// This function behaves like [`std::fs::canonicalize`], except that on Linux it
97/// detects and preserves namespace boundary prefixes:
98/// - `/proc/PID/root`, `/proc/PID/cwd`
99/// - `/proc/PID/task/TID/root`, `/proc/PID/task/TID/cwd`
100/// - `/proc/self/root`, `/proc/self/cwd`
101/// - `/proc/thread-self/root`, `/proc/thread-self/cwd`
102///
103/// # Examples
104///
105/// ```rust
106/// # #[cfg(target_os = "linux")]
107/// # fn main() -> std::io::Result<()> {
108/// use std::path::PathBuf;
109/// use proc_canonicalize::canonicalize;
110///
111/// // On Linux, the namespace prefix is preserved
112/// let path = "/proc/self/root";
113/// let canonical = canonicalize(path)?;
114/// assert_eq!(canonical, PathBuf::from("/proc/self/root"));
115/// # Ok(())
116/// # }
117/// # #[cfg(not(target_os = "linux"))]
118/// # fn main() {}
119/// ```
120///
121/// # Why This Matters
122///
123/// `std::fs::canonicalize("/proc/1234/root")` returns `/` because the kernel's
124/// `readlink()` on that magic symlink returns `/`. This breaks security boundaries
125/// for container tooling that needs to access container filesystems via `/proc/PID/root`.
126///
127/// # Platform Behavior
128///
129/// - **Linux**: Preserves `/proc/PID/root` and `/proc/PID/cwd` prefixes
130/// - **Other platforms**: Identical to `std::fs::canonicalize`
131///
132/// # Errors
133///
134/// Returns an error if:
135/// - The path does not exist
136/// - The process lacks permission to access the path
137/// - An I/O error occurs during resolution
138pub fn canonicalize(path: impl AsRef<Path>) -> io::Result<PathBuf> {
139    canonicalize_impl(path.as_ref())
140}
141
142#[cfg(target_os = "linux")]
143fn canonicalize_impl(path: &Path) -> io::Result<PathBuf> {
144    // Check if path contains a /proc namespace boundary
145    if let Some((namespace_prefix, remainder)) = find_namespace_boundary(path) {
146        // Verify the namespace prefix exists and is accessible
147        // We use metadata() to check existence and permissions, which gives better error messages
148        // than exists() (e.g. PermissionDenied vs NotFound)
149        std::fs::metadata(&namespace_prefix)?;
150
151        if remainder.as_os_str().is_empty() {
152            // Path IS the namespace boundary (e.g., "/proc/1234/root")
153            Ok(namespace_prefix)
154        } else {
155            // Path goes through namespace boundary (e.g., "/proc/1234/root/etc/passwd")
156
157            // 1. Resolve the namespace prefix to its absolute path on the host.
158            // This is necessary because /proc/PID/root might not be "/" (e.g. in containers),
159            // and /proc/PID/cwd is almost certainly not "/".
160            let resolved_prefix = std::fs::canonicalize(&namespace_prefix)?;
161
162            // 2. Canonicalize the full path.
163            // This traverses the magic link and resolves everything.
164            let full_path = namespace_prefix.join(&remainder);
165            let canonicalized = std::fs::canonicalize(full_path)?;
166
167            // 3. Try to re-base the canonicalized path onto the namespace prefix.
168            // We do this by stripping the resolved prefix from the canonicalized path.
169            if let Ok(suffix) = canonicalized.strip_prefix(&resolved_prefix) {
170                // The path is within the namespace. Re-attach the prefix.
171                Ok(namespace_prefix.join(suffix))
172            } else {
173                // The path escaped the namespace (e.g. via ".." or symlinks to outside).
174                // In this case, we cannot preserve the prefix while being correct.
175                // We return the fully resolved path (absolute path on host).
176                Ok(canonicalized)
177            }
178        }
179    } else {
180        // Check for indirect symlinks to /proc magic paths BEFORE calling std::fs::canonicalize.
181        //
182        // This handles cases like:
183        //   symlink("/proc/self/root", "/tmp/container_link")
184        //   canonicalize("/tmp/container_link")        -> should return /proc/self/root, not /
185        //   canonicalize("/tmp/container_link/etc")    -> should return /proc/self/root/etc, not /etc
186        //
187        // We detect symlinks in the path that point to /proc magic paths and handle them
188        // the same way we handle direct /proc paths.
189        if let Some(magic_path) = detect_indirect_proc_magic_link(path)? {
190            // Found an indirect symlink to a /proc magic path
191            // Use our namespace-aware canonicalization on the reconstructed path
192            return canonicalize_impl(&magic_path);
193        }
194
195        // Normal path - use std::fs::canonicalize directly
196        std::fs::canonicalize(path)
197    }
198}
199
200#[cfg(not(target_os = "linux"))]
201fn canonicalize_impl(path: &Path) -> io::Result<PathBuf> {
202    // On non-Linux platforms, just use std::fs::canonicalize
203    #[cfg(all(feature = "dunce", windows))]
204    {
205        dunce::canonicalize(path)
206    }
207    #[cfg(not(all(feature = "dunce", windows)))]
208    {
209        std::fs::canonicalize(path)
210    }
211}
212
213/// Find a `/proc/PID/root` or `/proc/PID/cwd` namespace boundary in the path.
214///
215/// Returns `Some((namespace_prefix, remainder))` if found, where:
216/// - `namespace_prefix` is the boundary path (e.g., `/proc/1234/root`)
217/// - `remainder` is the path after the boundary (e.g., `etc/passwd`)
218///
219/// Returns `None` if the path doesn't contain a namespace boundary.
220#[cfg(target_os = "linux")]
221fn find_namespace_boundary(path: &Path) -> Option<(PathBuf, PathBuf)> {
222    let mut components = path.components();
223
224    // Must start with root "/"
225    if components.next() != Some(Component::RootDir) {
226        return None;
227    }
228
229    // Next must be "proc"
230    match components.next() {
231        Some(Component::Normal(s)) if s == "proc" => {}
232        _ => return None,
233    }
234
235    // Next must be a PID (digits), "self", or "thread-self"
236    let pid_component = match components.next() {
237        Some(Component::Normal(s)) => s,
238        _ => return None,
239    };
240
241    let pid_str = pid_component.to_string_lossy();
242    let is_valid_pid = pid_str == "self"
243        || pid_str == "thread-self"
244        || (!pid_str.is_empty() && pid_str.chars().all(|c| c.is_ascii_digit()));
245
246    if !is_valid_pid {
247        return None;
248    }
249
250    // Next component determines if it's a direct namespace or a task namespace
251    let next_component = match components.next() {
252        Some(Component::Normal(s)) => s,
253        _ => return None,
254    };
255
256    if next_component == "root" || next_component == "cwd" {
257        // /proc/PID/root or /proc/PID/cwd
258        let mut prefix = PathBuf::from("/proc");
259        prefix.push(pid_component);
260        prefix.push(next_component);
261
262        // Collect remaining components as the remainder
263        let remainder: PathBuf = components.collect();
264        Some((prefix, remainder))
265    } else if next_component == "task" {
266        // /proc/PID/task/TID/root or /proc/PID/task/TID/cwd
267
268        // Next must be TID (digits)
269        let tid_component = match components.next() {
270            Some(Component::Normal(s)) => s,
271            _ => return None,
272        };
273
274        let tid_str = tid_component.to_string_lossy();
275        if tid_str.is_empty() || !tid_str.chars().all(|c| c.is_ascii_digit()) {
276            return None;
277        }
278
279        // Next must be root or cwd
280        let ns_type = match components.next() {
281            Some(Component::Normal(s)) if s == "root" || s == "cwd" => s,
282            _ => return None,
283        };
284
285        let mut prefix = PathBuf::from("/proc");
286        prefix.push(pid_component);
287        prefix.push("task");
288        prefix.push(tid_component);
289        prefix.push(ns_type);
290
291        // Collect remaining components as the remainder
292        let remainder: PathBuf = components.collect();
293        Some((prefix, remainder))
294    } else {
295        None
296    }
297}
298
299/// Check if a path is a `/proc` magic path (`/proc/{pid}/root` or `/proc/{pid}/cwd`).
300///
301/// This checks whether the path matches patterns like:
302/// - `/proc/self/root`, `/proc/self/cwd`
303/// - `/proc/thread-self/root`, `/proc/thread-self/cwd`
304/// - `/proc/{numeric_pid}/root`, `/proc/{numeric_pid}/cwd`
305///
306/// The path may have additional components after the magic suffix (e.g., `/proc/self/root/etc`).
307#[cfg(target_os = "linux")]
308fn is_proc_magic_path(path: &Path) -> bool {
309    find_namespace_boundary(path).is_some()
310}
311
312/// Detect if a path contains an indirect symlink to a `/proc` magic path.
313///
314/// This walks the ancestor chain of the input path looking for symlinks that
315/// point to `/proc/.../root` or `/proc/.../cwd`.
316///
317/// Returns `Some(magic_path)` with any remaining suffix if found, or `None` otherwise.
318#[cfg(target_os = "linux")]
319fn detect_indirect_proc_magic_link(path: &Path) -> io::Result<Option<PathBuf>> {
320    let mut current_path = if path.is_absolute() {
321        path.to_path_buf()
322    } else {
323        std::env::current_dir()?.join(path)
324    };
325
326    let mut iterations = 0;
327
328    // We restart the scan whenever we resolve a symlink
329    'scan: loop {
330        if iterations >= MAX_SYMLINK_FOLLOWS {
331            return Ok(None);
332        }
333
334        // We CANNOT blindly normalize_path() here because if we have "symlink/..",
335        // normalize_path() will remove "symlink" and "..", completely missing the fact
336        // that "symlink" might point to a magic path.
337        //
338        // Instead, we must walk the components one by one. If we hit a symlink, we resolve it.
339        // If we hit "..", we pop from our accumulated path.
340
341        // Check if the path ITSELF is magic (e.g. after resolution)
342        // We still check this first because we might have just resolved a symlink to a magic path
343        if is_proc_magic_path(&current_path) {
344            return Ok(Some(current_path));
345        }
346
347        let mut accumulated = PathBuf::new();
348        let mut components = current_path.components().peekable();
349
350        if let Some(Component::RootDir) = components.peek() {
351            accumulated.push("/");
352            components.next();
353        }
354
355        while let Some(component) = components.next() {
356            match component {
357                Component::RootDir => {
358                    accumulated.push("/");
359                }
360                Component::CurDir => {}
361                Component::ParentDir => {
362                    accumulated.pop();
363                    // After popping, we might be at a magic path (e.g. /proc/self/root/etc/..)
364                    if is_proc_magic_path(&accumulated) {
365                        // Reconstruct full path from here to preserve the magic prefix
366                        let remainder: PathBuf = components.collect();
367                        return Ok(Some(accumulated.join(remainder)));
368                    }
369                }
370                Component::Normal(name) => {
371                    let next_path = accumulated.join(name);
372
373                    // Check symlink
374                    let metadata = match std::fs::symlink_metadata(&next_path) {
375                        Ok(m) => m,
376                        Err(_) => {
377                            accumulated.push(name);
378                            continue;
379                        }
380                    };
381
382                    if metadata.is_symlink() {
383                        // Found symlink!
384                        iterations += 1;
385                        let target = std::fs::read_link(&next_path)?;
386
387                        // Construct new path: accumulated (parent) + target + remainder
388                        let parent = next_path.parent().unwrap_or(Path::new("/"));
389                        let remainder: PathBuf = components.collect();
390
391                        let resolved = if target.is_relative() {
392                            parent.join(target)
393                        } else {
394                            target
395                        };
396
397                        current_path = resolved.join(remainder);
398                        continue 'scan; // Restart scan from root of new path
399                    }
400
401                    accumulated.push(name);
402                }
403                Component::Prefix(_) => unreachable!("Linux paths don't have prefixes"),
404            }
405        }
406
407        // If we reached here, we scanned the whole path and found no symlinks (or no more symlinks).
408        // And it wasn't magic (checked at start of loop).
409        // One final check on the accumulated path (which is effectively normalized now)
410        if is_proc_magic_path(&accumulated) {
411            return Ok(Some(accumulated));
412        }
413
414        return Ok(None);
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    #[cfg(target_os = "linux")]
423    mod linux {
424        use super::*;
425
426        #[test]
427        fn test_find_namespace_boundary_proc_pid_root() {
428            let (prefix, remainder) =
429                find_namespace_boundary(Path::new("/proc/1234/root/etc/passwd")).unwrap();
430            assert_eq!(prefix, PathBuf::from("/proc/1234/root"));
431            assert_eq!(remainder, PathBuf::from("etc/passwd"));
432        }
433
434        #[test]
435        fn test_find_namespace_boundary_proc_pid_cwd() {
436            let (prefix, remainder) =
437                find_namespace_boundary(Path::new("/proc/5678/cwd/some/file.txt")).unwrap();
438            assert_eq!(prefix, PathBuf::from("/proc/5678/cwd"));
439            assert_eq!(remainder, PathBuf::from("some/file.txt"));
440        }
441
442        #[test]
443        fn test_find_namespace_boundary_proc_self_root() {
444            let (prefix, remainder) =
445                find_namespace_boundary(Path::new("/proc/self/root/etc/passwd")).unwrap();
446            assert_eq!(prefix, PathBuf::from("/proc/self/root"));
447            assert_eq!(remainder, PathBuf::from("etc/passwd"));
448        }
449
450        #[test]
451        fn test_find_namespace_boundary_proc_thread_self_root() {
452            let (prefix, remainder) =
453                find_namespace_boundary(Path::new("/proc/thread-self/root/app/config")).unwrap();
454            assert_eq!(prefix, PathBuf::from("/proc/thread-self/root"));
455            assert_eq!(remainder, PathBuf::from("app/config"));
456        }
457
458        #[test]
459        fn test_find_namespace_boundary_just_prefix() {
460            let (prefix, remainder) =
461                find_namespace_boundary(Path::new("/proc/1234/root")).unwrap();
462            assert_eq!(prefix, PathBuf::from("/proc/1234/root"));
463            assert_eq!(remainder, PathBuf::from(""));
464        }
465
466        #[test]
467        fn test_find_namespace_boundary_normal_path() {
468            assert!(find_namespace_boundary(Path::new("/home/user/file.txt")).is_none());
469        }
470
471        #[test]
472        fn test_find_namespace_boundary_proc_but_not_namespace() {
473            // /proc/1234/status is NOT a namespace boundary
474            assert!(find_namespace_boundary(Path::new("/proc/1234/status")).is_none());
475            assert!(find_namespace_boundary(Path::new("/proc/1234/exe")).is_none());
476            assert!(find_namespace_boundary(Path::new("/proc/1234/fd/0")).is_none());
477        }
478
479        #[test]
480        fn test_find_namespace_boundary_relative_path() {
481            assert!(find_namespace_boundary(Path::new("proc/1234/root")).is_none());
482        }
483
484        #[test]
485        fn test_find_namespace_boundary_invalid_pid() {
486            assert!(find_namespace_boundary(Path::new("/proc/abc/root")).is_none());
487            assert!(find_namespace_boundary(Path::new("/proc/123abc/root")).is_none());
488            assert!(find_namespace_boundary(Path::new("/proc//root")).is_none());
489        }
490
491        #[test]
492        fn test_canonicalize_proc_self_root() {
493            // /proc/self/root should return itself, not "/"
494            let result = canonicalize("/proc/self/root").expect("should succeed");
495            assert_eq!(result, PathBuf::from("/proc/self/root"));
496
497            // Contrast with std::fs::canonicalize which returns "/"
498            let std_result = std::fs::canonicalize("/proc/self/root").expect("should succeed");
499            assert_eq!(std_result, PathBuf::from("/"));
500
501            // They should be different!
502            assert_ne!(result, std_result);
503        }
504
505        #[test]
506        fn test_canonicalize_proc_self_root_subpath() {
507            // Test with a subpath that exists
508            let result = canonicalize("/proc/self/root/etc").expect("should succeed");
509            assert!(
510                result.starts_with("/proc/self/root"),
511                "should preserve /proc/self/root prefix, got: {:?}",
512                result
513            );
514        }
515
516        #[test]
517        fn test_canonicalize_normal_path() {
518            // Normal paths should behave like std::fs::canonicalize
519            let tmp = std::env::temp_dir();
520            let our_result = canonicalize(&tmp).expect("should succeed");
521            let std_result = std::fs::canonicalize(&tmp).expect("should succeed");
522            assert_eq!(our_result, std_result);
523        }
524
525        #[test]
526        fn test_canonicalize_proc_pid_root() {
527            use std::process;
528            let pid = process::id();
529            let proc_pid_root = format!("/proc/{}/root", pid);
530
531            let result = canonicalize(&proc_pid_root).expect("should succeed");
532            assert_eq!(result, PathBuf::from(&proc_pid_root));
533
534            // std would return "/"
535            let std_result = std::fs::canonicalize(&proc_pid_root).expect("should succeed");
536            assert_eq!(std_result, PathBuf::from("/"));
537        }
538
539        #[test]
540        fn test_canonicalize_proc_self_cwd() {
541            // /proc/self/cwd should also be preserved
542            let result = canonicalize("/proc/self/cwd").expect("should succeed");
543            assert_eq!(result, PathBuf::from("/proc/self/cwd"));
544        }
545
546        #[test]
547        fn test_canonicalize_nonexistent_file_under_namespace() {
548            // Non-existent file under valid namespace should return NotFound error
549            let result = canonicalize("/proc/self/root/this_file_definitely_does_not_exist_12345");
550            assert!(result.is_err());
551            let err = result.unwrap_err();
552            assert_eq!(err.kind(), io::ErrorKind::NotFound);
553        }
554
555        #[test]
556        fn test_canonicalize_nonexistent_pid() {
557            // Very high PID that almost certainly doesn't exist
558            let result = canonicalize("/proc/4294967295/root");
559            assert!(result.is_err());
560            let err = result.unwrap_err();
561            assert_eq!(err.kind(), io::ErrorKind::NotFound);
562        }
563
564        #[test]
565        fn test_canonicalize_with_dotdot_normalization() {
566            // Path with .. that should be normalized but stay within namespace
567            let result = canonicalize("/proc/self/root/etc/../etc/passwd");
568            // This should either succeed (if /etc/passwd exists) or fail with NotFound
569            // But if it succeeds, it must preserve the namespace prefix
570            if let Ok(path) = result {
571                assert!(
572                    path.starts_with("/proc/self/root"),
573                    "should preserve namespace prefix, got: {:?}",
574                    path
575                );
576            }
577        }
578
579        #[test]
580        fn test_canonicalize_with_dotdot_at_boundary() {
581            // Try to escape with .. - should still be contained
582            // /proc/self/root/../root/etc should resolve within namespace
583            let result = canonicalize("/proc/self/root/tmp/../etc");
584            if let Ok(path) = result {
585                assert!(
586                    path.starts_with("/proc/self/root"),
587                    "should preserve namespace prefix even with .., got: {:?}",
588                    path
589                );
590            }
591        }
592
593        #[test]
594        fn test_canonicalize_deep_nested_path() {
595            // Deep nested path under namespace
596            let result = canonicalize("/proc/self/root/usr/share/doc");
597            if let Ok(path) = result {
598                assert!(
599                    path.starts_with("/proc/self/root"),
600                    "should preserve namespace prefix for deep paths, got: {:?}",
601                    path
602                );
603            }
604        }
605
606        #[test]
607        fn test_canonicalize_trailing_slash() {
608            // Trailing slash should still work
609            let result = canonicalize("/proc/self/root/");
610            // Note: std::fs::canonicalize typically strips trailing slashes
611            if let Ok(path) = result {
612                assert!(
613                    path.starts_with("/proc/self/root"),
614                    "should handle trailing slash, got: {:?}",
615                    path
616                );
617            }
618        }
619
620        #[test]
621        fn test_canonicalize_thread_self() {
622            // /proc/thread-self/root should also work
623            let result = canonicalize("/proc/thread-self/root");
624            if let Ok(path) = result {
625                assert_eq!(path, PathBuf::from("/proc/thread-self/root"));
626            }
627            // Note: thread-self might not exist on all systems, so we allow failure
628        }
629
630        #[test]
631        fn test_canonicalize_symlink_resolution_within_namespace() {
632            // /etc/mtab is often a symlink - verify symlinks are resolved
633            // but namespace prefix is preserved
634            let result = canonicalize("/proc/self/root/etc/mtab");
635            if let Ok(path) = result {
636                assert!(
637                    path.starts_with("/proc/self/root"),
638                    "symlink resolution should preserve namespace, got: {:?}",
639                    path
640                );
641            }
642        }
643
644        #[test]
645        fn test_find_namespace_boundary_with_trailing_slash() {
646            // Path with trailing slash
647            let result = find_namespace_boundary(Path::new("/proc/1234/root/"));
648            assert!(result.is_some());
649            let (prefix, _remainder) = result.unwrap();
650            assert_eq!(prefix, PathBuf::from("/proc/1234/root"));
651            // Remainder might be empty or contain just a component depending on how trailing slash is parsed
652        }
653
654        #[test]
655        fn test_find_namespace_boundary_with_dots() {
656            // Path with . and .. components - these get normalized by Path
657            let result = find_namespace_boundary(Path::new("/proc/1234/root/./etc/../etc"));
658            assert!(result.is_some());
659            let (prefix, _remainder) = result.unwrap();
660            assert_eq!(prefix, PathBuf::from("/proc/1234/root"));
661            // Remainder will contain the unnormalized path components
662        }
663
664        #[test]
665        fn test_canonicalize_permission_denied() {
666            // Try to access another process's namespace without permission
667            // PID 1 is usually init and may have restricted access
668            let result = canonicalize("/proc/1/root/etc/shadow");
669            // This should either succeed or fail with PermissionDenied or NotFound
670            // depending on system configuration
671            if let Err(e) = result {
672                assert!(
673                    e.kind() == io::ErrorKind::PermissionDenied
674                        || e.kind() == io::ErrorKind::NotFound,
675                    "expected PermissionDenied or NotFound, got: {:?}",
676                    e.kind()
677                );
678            }
679        }
680
681        #[test]
682        fn test_canonicalize_pid_1_root() {
683            // PID 1 is always init/systemd - a real external process
684            // This is the realistic scenario: accessing another process's namespace
685            let result = canonicalize("/proc/1/root");
686
687            match result {
688                Ok(path) => {
689                    // If we have permission, the prefix MUST be preserved
690                    assert_eq!(
691                        path,
692                        PathBuf::from("/proc/1/root"),
693                        "must preserve /proc/1/root prefix"
694                    );
695
696                    // Verify std::fs::canonicalize would return "/" (the problem we're fixing)
697                    let std_result =
698                        std::fs::canonicalize("/proc/1/root").expect("std should also succeed");
699                    assert_eq!(std_result, PathBuf::from("/"), "std resolves to /");
700                }
701                Err(e) => {
702                    // Permission denied is acceptable - we're accessing another process
703                    assert!(
704                        e.kind() == io::ErrorKind::PermissionDenied
705                            || e.kind() == io::ErrorKind::NotFound,
706                        "expected PermissionDenied or NotFound, got: {:?}",
707                        e.kind()
708                    );
709                }
710            }
711        }
712
713        #[test]
714        fn test_canonicalize_pid_1_root_subpath() {
715            // Access a file through PID 1's namespace - realistic container scenario
716            let result = canonicalize("/proc/1/root/etc/hostname");
717
718            match result {
719                Ok(path) => {
720                    // Path MUST preserve the namespace boundary
721                    assert!(
722                        path.starts_with("/proc/1/root"),
723                        "must preserve /proc/1/root prefix, got: {:?}",
724                        path
725                    );
726                }
727                Err(e) => {
728                    // Permission denied or file not found is acceptable
729                    assert!(
730                        e.kind() == io::ErrorKind::PermissionDenied
731                            || e.kind() == io::ErrorKind::NotFound,
732                        "expected PermissionDenied or NotFound, got: {:?}",
733                        e.kind()
734                    );
735                }
736            }
737        }
738
739        #[test]
740        fn test_canonicalize_pid_1_cwd() {
741            // Test /proc/1/cwd - the working directory of init
742            let result = canonicalize("/proc/1/cwd");
743
744            match result {
745                Ok(path) => {
746                    assert_eq!(
747                        path,
748                        PathBuf::from("/proc/1/cwd"),
749                        "must preserve /proc/1/cwd"
750                    );
751                }
752                Err(e) => {
753                    assert!(
754                        e.kind() == io::ErrorKind::PermissionDenied
755                            || e.kind() == io::ErrorKind::NotFound,
756                        "expected PermissionDenied or NotFound, got: {:?}",
757                        e.kind()
758                    );
759                }
760            }
761        }
762
763        #[test]
764        fn test_self_vs_pid_equivalence() {
765            // /proc/self/root and /proc/{our_pid}/root should behave the same
766            use std::process;
767            let pid = process::id();
768
769            let self_result = canonicalize("/proc/self/root").expect("self should work");
770            let pid_result = canonicalize(format!("/proc/{}/root", pid)).expect("pid should work");
771
772            // Both should preserve their respective prefixes
773            assert_eq!(self_result, PathBuf::from("/proc/self/root"));
774            assert_eq!(pid_result, PathBuf::from(format!("/proc/{}/root", pid)));
775        }
776
777        /// Tests for indirect symlinks pointing to /proc/PID/root magic paths.
778        ///
779        /// These test the security vulnerability where a symlink outside /proc
780        /// points to a /proc magic path, bypassing the lexical prefix check.
781        mod indirect_symlink_tests {
782            use super::*;
783            use std::os::unix::fs::symlink;
784
785            #[test]
786            fn test_indirect_symlink_to_proc_self_root() {
787                // Create a symlink outside /proc that points to /proc/self/root
788                let temp = tempfile::tempdir().expect("failed to create temp dir");
789                let link_path = temp.path().join("link_to_proc");
790
791                // Create symlink: link_to_proc -> /proc/self/root
792                symlink("/proc/self/root", &link_path).expect("failed to create symlink");
793
794                let result = canonicalize(&link_path).expect("canonicalize should succeed");
795
796                // CRITICAL: Must NOT be "/" - that would be the security bypass
797                assert_ne!(
798                    result,
799                    PathBuf::from("/"),
800                    "SECURITY BUG: Indirect symlink to /proc/self/root resolved to /"
801                );
802
803                // Should preserve the /proc/self/root prefix
804                assert!(
805                    result.starts_with("/proc/self/root"),
806                    "Expected /proc/self/root prefix, got: {:?}",
807                    result
808                );
809            }
810
811            #[test]
812            fn test_indirect_symlink_with_suffix() {
813                // Create a symlink and then access a path through it
814                let temp = tempfile::tempdir().expect("failed to create temp dir");
815                let link_path = temp.path().join("container");
816
817                // Create symlink: container -> /proc/self/root
818                symlink("/proc/self/root", &link_path).expect("failed to create symlink");
819
820                // Canonicalize a path THROUGH the symlink
821                let result =
822                    canonicalize(link_path.join("etc")).expect("canonicalize should succeed");
823
824                // Should be /proc/self/root/etc, NOT /etc
825                assert!(
826                    result.starts_with("/proc/self/root"),
827                    "Expected /proc/self/root prefix, got: {:?}",
828                    result
829                );
830            }
831
832            #[test]
833            fn test_chained_symlinks_to_proc() {
834                // Create chain: link1 -> link2 -> /proc/self/root
835                let temp = tempfile::tempdir().expect("failed to create temp dir");
836
837                let link2 = temp.path().join("link2");
838                let link1 = temp.path().join("link1");
839
840                symlink("/proc/self/root", &link2).expect("failed to create link2");
841                symlink(&link2, &link1).expect("failed to create link1");
842
843                let result = canonicalize(&link1).expect("canonicalize should succeed");
844
845                // Should preserve /proc prefix even through chain
846                assert!(
847                    result.starts_with("/proc/self/root"),
848                    "Chained symlinks should preserve /proc prefix, got: {:?}",
849                    result
850                );
851            }
852
853            #[test]
854            fn test_indirect_symlink_to_proc_pid_root() {
855                // Test with actual PID (our own process)
856                use std::process;
857                let pid = process::id();
858                let proc_path = format!("/proc/{}/root", pid);
859
860                let temp = tempfile::tempdir().expect("failed to create temp dir");
861                let link_path = temp.path().join("pid_link");
862
863                symlink(proc_path.as_str(), &link_path).expect("failed to create symlink");
864
865                let result = canonicalize(&link_path).expect("canonicalize should succeed");
866
867                // Should NOT be "/"
868                assert_ne!(
869                    result,
870                    PathBuf::from("/"),
871                    "SECURITY BUG: Indirect symlink to /proc/{}/root resolved to /",
872                    pid
873                );
874
875                // Should preserve the /proc/PID/root prefix
876                assert!(
877                    result.starts_with(format!("/proc/{}/root", pid)),
878                    "Expected /proc/{}/root prefix, got: {:?}",
879                    pid,
880                    result
881                );
882            }
883
884            #[test]
885            fn test_indirect_symlink_to_proc_self_cwd() {
886                // Same vulnerability applies to /proc/self/cwd
887                let temp = tempfile::tempdir().expect("failed to create temp dir");
888                let link_path = temp.path().join("cwd_link");
889
890                symlink("/proc/self/cwd", &link_path).expect("failed to create symlink");
891
892                let result = canonicalize(&link_path).expect("canonicalize should succeed");
893
894                // Should preserve the /proc/self/cwd prefix
895                assert!(
896                    result.starts_with("/proc/self/cwd"),
897                    "Expected /proc/self/cwd prefix, got: {:?}",
898                    result
899                );
900            }
901
902            #[test]
903            fn test_indirect_symlink_to_proc_thread_self_root() {
904                // Test thread-self variant
905                let temp = tempfile::tempdir().expect("failed to create temp dir");
906                let link_path = temp.path().join("thread_link");
907
908                symlink("/proc/thread-self/root", &link_path).expect("failed to create symlink");
909
910                // thread-self might not exist on all systems
911                if let Ok(result) = canonicalize(&link_path) {
912                    assert!(
913                        result.starts_with("/proc/thread-self/root"),
914                        "Expected /proc/thread-self/root prefix, got: {:?}",
915                        result
916                    );
917                }
918            }
919
920            #[test]
921            fn test_normal_symlink_not_affected() {
922                // Ensure normal symlinks (not pointing to /proc magic) still work
923                let temp = tempfile::tempdir().expect("failed to create temp dir");
924                let target = temp.path().join("target");
925                let link = temp.path().join("link");
926
927                std::fs::create_dir(&target).expect("failed to create target dir");
928                symlink(&target, &link).expect("failed to create symlink");
929
930                let result = canonicalize(&link).expect("canonicalize should succeed");
931                let std_result =
932                    std::fs::canonicalize(&link).expect("std canonicalize should succeed");
933
934                // Normal symlinks should resolve identically to std
935                assert_eq!(result, std_result);
936            }
937
938            #[test]
939            fn test_symlink_loop_does_not_hang() {
940                // Ensure we handle symlink loops gracefully
941                let temp = tempfile::tempdir().expect("failed to create temp dir");
942                let link_a = temp.path().join("link_a");
943                let link_b = temp.path().join("link_b");
944
945                // Create circular symlinks
946                symlink(&link_b, &link_a).expect("failed to create link_a");
947                symlink(&link_a, &link_b).expect("failed to create link_b");
948
949                // Should return an error (too many symlinks), not hang
950                let result = canonicalize(&link_a);
951                assert!(result.is_err(), "Symlink loop should return error");
952            }
953        }
954
955        /// Security-focused tests for potential attack vectors.
956        ///
957        /// These tests verify protection against common path-based attacks
958        /// including path traversal, symlink escapes, and edge cases.
959        mod security_tests {
960            use super::*;
961
962            #[test]
963            fn test_path_traversal_many_dotdot_at_boundary() {
964                // Attempt to escape namespace with excessive .. components
965                // /proc/self/root/../../../../../../../etc/passwd
966                let result = canonicalize("/proc/self/root/../../../../../../../etc/passwd");
967
968                // This should either:
969                // 1. Preserve namespace prefix (if path resolves within)
970                // 2. Error out (if path is invalid)
971                // But NEVER resolve to /etc/passwd on the host
972                if let Ok(path) = result {
973                    assert!(
974                        path.starts_with("/proc/self/root"),
975                        "Path traversal should not escape namespace, got: {:?}",
976                        path
977                    );
978                }
979            }
980
981            #[test]
982            fn test_canonicalize_idempotency() {
983                // Security property: canonicalize(canonicalize(x)) == canonicalize(x)
984                // If not idempotent, attackers could exploit the difference
985                let test_paths = ["/proc/self/root", "/proc/self/root/etc", "/proc/self/cwd"];
986
987                for path in &test_paths {
988                    if let Ok(first) = canonicalize(path) {
989                        if let Ok(second) = canonicalize(&first) {
990                            assert_eq!(
991                                first, second,
992                                "canonicalize should be idempotent for {:?}",
993                                path
994                            );
995                        }
996                    }
997                }
998            }
999
1000            #[test]
1001            fn test_case_sensitivity_proc() {
1002                // Linux is case-sensitive: /PROC should NOT match /proc
1003                // This verifies we don't accidentally treat /PROC as a namespace
1004                let result = canonicalize("/PROC/self/root");
1005
1006                // /PROC/self/root should not exist (case-sensitive filesystem)
1007                // or if it somehow does, it should not be treated as a namespace
1008                match result {
1009                    Ok(path) => {
1010                        // If it somehow exists, it should NOT have /proc protection
1011                        // (would be treated as normal path)
1012                        assert!(
1013                            !path.starts_with("/proc/"),
1014                            "/PROC should not be treated as /proc namespace"
1015                        );
1016                    }
1017                    Err(e) => {
1018                        // Expected: NotFound because /PROC doesn't exist
1019                        assert_eq!(e.kind(), io::ErrorKind::NotFound);
1020                    }
1021                }
1022            }
1023
1024            #[test]
1025            fn test_double_slash_normalization() {
1026                // Paths with double slashes: //proc/self/root or /proc//self//root
1027                // Verify they're handled correctly
1028                let result = canonicalize("/proc/self/root");
1029                if let Ok(normal) = result {
1030                    // Path::new normalizes double slashes, so this should work the same
1031                    let double_slash = canonicalize("//proc//self//root");
1032                    if let Ok(ds_path) = double_slash {
1033                        assert_eq!(normal, ds_path, "Double slashes should normalize correctly");
1034                    }
1035                }
1036            }
1037
1038            #[test]
1039            fn test_trailing_slash_consistency() {
1040                // /proc/self/root vs /proc/self/root/ should behave consistently
1041                let without_slash = canonicalize("/proc/self/root");
1042                let with_slash = canonicalize("/proc/self/root/");
1043
1044                if let (Ok(a), Ok(b)) = (without_slash, with_slash) {
1045                    // Both should preserve the namespace
1046                    assert!(a.starts_with("/proc/self/root"));
1047                    assert!(b.starts_with("/proc/self/root"));
1048                }
1049                // If either fails, that's fine for this test
1050            }
1051
1052            #[test]
1053            fn test_dot_components() {
1054                // /proc/self/root/./etc should normalize to /proc/self/root/etc
1055                let result = canonicalize("/proc/self/root/./etc");
1056                if let Ok(path) = result {
1057                    assert!(
1058                        path.starts_with("/proc/self/root"),
1059                        "Dot components should preserve namespace, got: {:?}",
1060                        path
1061                    );
1062                    // Should not contain /./
1063                    assert!(
1064                        !path.to_string_lossy().contains("/./"),
1065                        "Dot should be normalized out"
1066                    );
1067                }
1068            }
1069
1070            #[test]
1071            fn test_symlink_within_namespace_relative_escape_attempt() {
1072                // Create a symlink inside a temp dir that tries to escape via relative path
1073                // This tests symlink resolution staying within bounds
1074                use std::os::unix::fs::symlink;
1075
1076                let temp = tempfile::tempdir().expect("failed to create temp dir");
1077                let subdir = temp.path().join("subdir");
1078                std::fs::create_dir(&subdir).expect("failed to create subdir");
1079
1080                // Create a symlink that tries to escape: subdir/escape -> ../../../../../../etc
1081                let escape_link = subdir.join("escape");
1082                symlink("../../../../../../etc", &escape_link).expect("failed to create symlink");
1083
1084                // Canonicalizing should resolve but this is a normal symlink
1085                // (not through /proc), so std behavior applies
1086                let result = canonicalize(&escape_link);
1087                // Just verify it doesn't panic and behaves like std
1088                if let Ok(path) = &result {
1089                    let std_result = std::fs::canonicalize(&escape_link);
1090                    if let Ok(std_path) = std_result {
1091                        assert_eq!(*path, std_path);
1092                    }
1093                }
1094            }
1095
1096            #[test]
1097            fn test_empty_path() {
1098                // Empty path should error
1099                let result = canonicalize("");
1100                assert!(result.is_err(), "Empty path should error");
1101            }
1102
1103            #[test]
1104            fn test_relative_path_not_mistaken_for_proc() {
1105                // A relative path "proc/self/root" should NOT be treated as /proc/self/root
1106                let result = canonicalize("proc/self/root");
1107
1108                // Should either error (doesn't exist) or resolve relative to cwd
1109                // But should NOT get namespace treatment
1110                // The key verification is that find_namespace_boundary rejects relative paths
1111                let _ = result; // Result depends on whether relative path exists
1112            }
1113
1114            #[test]
1115            fn test_proc_without_pid() {
1116                // /proc/root (missing PID) should not be treated as namespace boundary
1117                let result = find_namespace_boundary(Path::new("/proc/root"));
1118                assert!(
1119                    result.is_none(),
1120                    "/proc/root (no PID) should not be a namespace boundary"
1121                );
1122            }
1123
1124            #[test]
1125            fn test_proc_invalid_special_names() {
1126                // Only "self" and "thread-self" are valid special PIDs
1127                // Others like "parent" or "init" should not be treated as namespace
1128                for invalid in &["parent", "init", "current", "me"] {
1129                    let path = format!("/proc/{}/root", invalid);
1130                    let result = find_namespace_boundary(Path::new(&path));
1131                    assert!(
1132                        result.is_none(),
1133                        "/proc/{}/root should not be a namespace boundary",
1134                        invalid
1135                    );
1136                }
1137            }
1138
1139            #[test]
1140            fn test_very_long_pid() {
1141                // PIDs have a max value (typically 4194304 on 64-bit Linux)
1142                // But we accept any numeric string - verify no overflow/panic
1143                let long_pid = "9".repeat(100);
1144                let path = format!("/proc/{}/root", long_pid);
1145                let result = find_namespace_boundary(Path::new(&path));
1146                // Should be detected as a namespace boundary (syntactically valid)
1147                assert!(
1148                    result.is_some(),
1149                    "Very long numeric PID should be syntactically accepted"
1150                );
1151            }
1152
1153            #[test]
1154            fn test_pid_zero() {
1155                // PID 0 is the kernel scheduler, not a real process
1156                // But syntactically it's a valid PID format
1157                let result = find_namespace_boundary(Path::new("/proc/0/root"));
1158                assert!(result.is_some(), "PID 0 is syntactically valid");
1159
1160                // Canonicalizing will likely fail since /proc/0/root doesn't exist
1161                let canon = canonicalize("/proc/0/root");
1162                assert!(canon.is_err(), "/proc/0/root should not exist");
1163            }
1164
1165            #[test]
1166            fn test_negative_pid_rejected() {
1167                // Negative PIDs are invalid
1168                let result = find_namespace_boundary(Path::new("/proc/-1/root"));
1169                assert!(
1170                    result.is_none(),
1171                    "Negative PID should not be a namespace boundary"
1172                );
1173            }
1174
1175            #[test]
1176            fn test_pid_with_leading_zeros() {
1177                // PIDs like "0001234" - are these valid?
1178                // Syntactically they're all digits, so we accept them
1179                let result = find_namespace_boundary(Path::new("/proc/0001234/root"));
1180                assert!(
1181                    result.is_some(),
1182                    "PID with leading zeros is syntactically valid"
1183                );
1184            }
1185
1186            #[test]
1187            fn test_symlink_to_proc_subpath() {
1188                // Symlink pointing deep into /proc: link -> /proc/self/root/etc
1189                use std::os::unix::fs::symlink;
1190                let temp = tempfile::tempdir().expect("failed to create temp dir");
1191                let link = temp.path().join("deep_link");
1192                symlink("/proc/self/root/etc", &link).expect("failed to create symlink");
1193
1194                let result = canonicalize(&link);
1195                if let Ok(path) = result {
1196                    assert!(
1197                        path.starts_with("/proc/self/root"),
1198                        "Symlink to /proc subpath should preserve prefix, got: {:?}",
1199                        path
1200                    );
1201                }
1202            }
1203
1204            #[test]
1205            fn test_symlink_interception() {
1206                // link1 -> link2 -> /proc/self/root
1207                use std::os::unix::fs::symlink;
1208                let temp = tempfile::tempdir().expect("failed to create temp dir");
1209                let link2 = temp.path().join("link2");
1210                let link1 = temp.path().join("link1");
1211
1212                symlink("/proc/self/root", &link2).expect("failed to create link2");
1213                symlink(&link2, &link1).expect("failed to create link1");
1214
1215                let result = canonicalize(&link1).expect("should succeed");
1216                assert!(
1217                    result.starts_with("/proc/self/root"),
1218                    "Chain of symlinks should be detected"
1219                );
1220            }
1221
1222            #[test]
1223            fn test_symlink_to_relative_proc_name() {
1224                // link -> "proc/self/root" (relative path, not absolute /proc)
1225                // This should NOT be treated as magic unless it resolves to absolute /proc
1226                use std::os::unix::fs::symlink;
1227                let temp = tempfile::tempdir().expect("failed to create temp dir");
1228                let link = temp.path().join("rel_link");
1229
1230                // Create a fake proc dir locally to make the link valid
1231                let fake_proc = temp.path().join("proc/self/root");
1232                std::fs::create_dir_all(fake_proc).expect("failed to create fake proc");
1233
1234                symlink("proc/self/root", &link).expect("failed to create symlink");
1235
1236                let result = canonicalize(&link).expect("should succeed");
1237
1238                // Should resolve to the temp dir path, NOT /proc/self/root
1239                assert!(
1240                    !result.starts_with("/proc/self/root"),
1241                    "Relative path looking like proc should not be magic"
1242                );
1243                assert!(
1244                    result.starts_with(temp.path()),
1245                    "Should resolve to temp dir"
1246                );
1247            }
1248        }
1249    }
1250
1251    #[cfg(not(target_os = "linux"))]
1252    mod non_linux {
1253        use super::*;
1254
1255        #[test]
1256        fn test_canonicalize_is_std_on_non_linux() {
1257            // On non-Linux, we just wrap std::fs::canonicalize
1258            let tmp = std::env::temp_dir();
1259            let our_result = canonicalize(&tmp).expect("should succeed");
1260            let std_result = std::fs::canonicalize(&tmp).expect("should succeed");
1261            // With dunce feature on Windows, our result is simplified but std returns UNC
1262            #[cfg(all(feature = "dunce", windows))]
1263            {
1264                let our_str = our_result.to_string_lossy();
1265                let std_str = std_result.to_string_lossy();
1266                // dunce should simplify the path
1267                assert!(!our_str.starts_with(r"\\?\"), "dunce should simplify path");
1268                assert!(std_str.starts_with(r"\\?\"), "std returns UNC format");
1269                // They should match except for the UNC prefix
1270                assert_eq!(our_str.as_ref(), std_str.trim_start_matches(r"\\?\"));
1271            }
1272            // Without dunce (or on non-Windows), they should match exactly
1273            #[cfg(not(all(feature = "dunce", windows)))]
1274            {
1275                assert_eq!(our_result, std_result);
1276            }
1277        }
1278    }
1279}