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