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//! ```text
13//! std::fs::canonicalize("/proc/1234/root")           -> "/"
14//! std::fs::canonicalize("/proc/1234/root/etc/passwd") -> "/etc/passwd"
15//! ```
16//!
17//! This breaks security tools that use `/proc/PID/root` as a boundary for container
18//! filesystem access, because the boundary resolves to the host root!
19//!
20//! ## The Fix
21//!
22//! This crate detects `/proc/PID/root` and `/proc/PID/cwd` prefixes and preserves them:
23//!
24//! ```text
25//! proc_canonicalize::canonicalize("/proc/1234/root")           -> "/proc/1234/root"
26//! proc_canonicalize::canonicalize("/proc/1234/root/etc/passwd") -> "/proc/1234/root/etc/passwd"
27//! ```
28//!
29//! For all other paths, behavior is identical to `std::fs::canonicalize`.
30//!
31//! ## Platform Support
32//!
33//! - **Linux**: Full functionality - preserves `/proc/PID/root` and `/proc/PID/cwd`
34//! - **Other platforms**: Falls back to `std::fs::canonicalize` (no-op)
35//!
36//! ## Zero Dependencies
37//!
38//! This crate has no dependencies beyond the Rust standard library.
39//!
40//! ## Optional Features
41//!
42//! - `dunce` (Windows only): Simplifies Windows extended-length paths by removing the `\\?\` prefix
43//!   when possible (e.g., `\\?\C:\foo` becomes `C:\foo`). Automatically preserves the prefix when
44//!   needed (e.g., for paths longer than 260 characters). Enable with `features = ["dunce"]`.
45
46#![forbid(unsafe_code)]
47#![warn(missing_docs)]
48
49use std::io;
50use std::path::{Path, PathBuf};
51
52#[cfg(target_os = "linux")]
53use std::path::Component;
54
55/// Canonicalize a path, preserving Linux `/proc/PID/root` and `/proc/PID/cwd` boundaries.
56///
57/// This function behaves like [`std::fs::canonicalize`], except that on Linux it
58/// detects and preserves namespace boundary prefixes (`/proc/PID/root`, `/proc/PID/cwd`,
59/// `/proc/self/root`, `/proc/self/cwd`, `/proc/thread-self/root`, `/proc/thread-self/cwd`).
60///
61/// # Why This Matters
62///
63/// `std::fs::canonicalize("/proc/1234/root")` returns `/` because the kernel's
64/// `readlink()` on that magic symlink returns `/`. This breaks security boundaries
65/// for container tooling that needs to access container filesystems via `/proc/PID/root`.
66///
67/// # Platform Behavior
68///
69/// - **Linux**: Preserves `/proc/PID/root` and `/proc/PID/cwd` prefixes
70/// - **Other platforms**: Identical to `std::fs::canonicalize`
71///
72/// # Errors
73///
74/// Returns an error if:
75/// - The path does not exist
76/// - The process lacks permission to access the path
77/// - An I/O error occurs during resolution
78pub fn canonicalize(path: impl AsRef<Path>) -> io::Result<PathBuf> {
79    canonicalize_impl(path.as_ref())
80}
81
82#[cfg(target_os = "linux")]
83fn canonicalize_impl(path: &Path) -> io::Result<PathBuf> {
84    // Check if path contains a /proc namespace boundary
85    if let Some((namespace_prefix, remainder)) = find_namespace_boundary(path) {
86        // Verify the namespace prefix exists and is accessible
87        if !namespace_prefix.exists() {
88            return Err(io::Error::new(
89                io::ErrorKind::NotFound,
90                format!(
91                    "namespace path does not exist: {}",
92                    namespace_prefix.display()
93                ),
94            ));
95        }
96
97        if remainder.as_os_str().is_empty() {
98            // Path IS the namespace boundary (e.g., "/proc/1234/root")
99            Ok(namespace_prefix)
100        } else {
101            // Path goes through namespace boundary (e.g., "/proc/1234/root/etc/passwd")
102            // Canonicalize the full path, then re-attach the namespace prefix
103            let full_path = namespace_prefix.join(&remainder);
104
105            // Use std::fs::canonicalize on the full path - this will traverse
106            // through /proc/PID/root correctly, but return a path without the prefix
107            let canonicalized = std::fs::canonicalize(full_path)?;
108
109            // The result will be something like "/etc/passwd" (the container's view)
110            // We need to re-attach the namespace prefix
111            Ok(namespace_prefix.join(canonicalized.strip_prefix("/").unwrap_or(&canonicalized)))
112        }
113    } else {
114        // Normal path - use std::fs::canonicalize directly
115        std::fs::canonicalize(path)
116    }
117}
118
119#[cfg(not(target_os = "linux"))]
120fn canonicalize_impl(path: &Path) -> io::Result<PathBuf> {
121    // On non-Linux platforms, just use std::fs::canonicalize
122    #[cfg(all(feature = "dunce", windows))]
123    {
124        dunce::canonicalize(path)
125    }
126    #[cfg(not(all(feature = "dunce", windows)))]
127    {
128        std::fs::canonicalize(path)
129    }
130}
131
132/// Find a `/proc/PID/root` or `/proc/PID/cwd` namespace boundary in the path.
133///
134/// Returns `Some((namespace_prefix, remainder))` if found, where:
135/// - `namespace_prefix` is the boundary path (e.g., `/proc/1234/root`)
136/// - `remainder` is the path after the boundary (e.g., `etc/passwd`)
137///
138/// Returns `None` if the path doesn't contain a namespace boundary.
139#[cfg(target_os = "linux")]
140fn find_namespace_boundary(path: &Path) -> Option<(PathBuf, PathBuf)> {
141    let mut components = path.components();
142
143    // Must start with root "/"
144    if components.next() != Some(Component::RootDir) {
145        return None;
146    }
147
148    // Next must be "proc"
149    match components.next() {
150        Some(Component::Normal(s)) if s == "proc" => {}
151        _ => return None,
152    }
153
154    // Next must be a PID (digits), "self", or "thread-self"
155    let pid_component = match components.next() {
156        Some(Component::Normal(s)) => s,
157        _ => return None,
158    };
159
160    let pid_str = pid_component.to_string_lossy();
161    let is_valid_pid = pid_str == "self"
162        || pid_str == "thread-self"
163        || (!pid_str.is_empty() && pid_str.chars().all(|c| c.is_ascii_digit()));
164
165    if !is_valid_pid {
166        return None;
167    }
168
169    // Next must be "root" or "cwd"
170    let ns_type = match components.next() {
171        Some(Component::Normal(s)) if s == "root" || s == "cwd" => s,
172        _ => return None,
173    };
174
175    // Build the namespace prefix: /proc/{pid}/{root|cwd}
176    let mut prefix = PathBuf::from("/proc");
177    prefix.push(pid_component);
178    prefix.push(ns_type);
179
180    // Collect remaining components as the remainder
181    let remainder: PathBuf = components.collect();
182
183    Some((prefix, remainder))
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[cfg(target_os = "linux")]
191    mod linux {
192        use super::*;
193
194        #[test]
195        fn test_find_namespace_boundary_proc_pid_root() {
196            let (prefix, remainder) =
197                find_namespace_boundary(Path::new("/proc/1234/root/etc/passwd")).unwrap();
198            assert_eq!(prefix, PathBuf::from("/proc/1234/root"));
199            assert_eq!(remainder, PathBuf::from("etc/passwd"));
200        }
201
202        #[test]
203        fn test_find_namespace_boundary_proc_pid_cwd() {
204            let (prefix, remainder) =
205                find_namespace_boundary(Path::new("/proc/5678/cwd/some/file.txt")).unwrap();
206            assert_eq!(prefix, PathBuf::from("/proc/5678/cwd"));
207            assert_eq!(remainder, PathBuf::from("some/file.txt"));
208        }
209
210        #[test]
211        fn test_find_namespace_boundary_proc_self_root() {
212            let (prefix, remainder) =
213                find_namespace_boundary(Path::new("/proc/self/root/etc/passwd")).unwrap();
214            assert_eq!(prefix, PathBuf::from("/proc/self/root"));
215            assert_eq!(remainder, PathBuf::from("etc/passwd"));
216        }
217
218        #[test]
219        fn test_find_namespace_boundary_proc_thread_self_root() {
220            let (prefix, remainder) =
221                find_namespace_boundary(Path::new("/proc/thread-self/root/app/config")).unwrap();
222            assert_eq!(prefix, PathBuf::from("/proc/thread-self/root"));
223            assert_eq!(remainder, PathBuf::from("app/config"));
224        }
225
226        #[test]
227        fn test_find_namespace_boundary_just_prefix() {
228            let (prefix, remainder) =
229                find_namespace_boundary(Path::new("/proc/1234/root")).unwrap();
230            assert_eq!(prefix, PathBuf::from("/proc/1234/root"));
231            assert_eq!(remainder, PathBuf::from(""));
232        }
233
234        #[test]
235        fn test_find_namespace_boundary_normal_path() {
236            assert!(find_namespace_boundary(Path::new("/home/user/file.txt")).is_none());
237        }
238
239        #[test]
240        fn test_find_namespace_boundary_proc_but_not_namespace() {
241            // /proc/1234/status is NOT a namespace boundary
242            assert!(find_namespace_boundary(Path::new("/proc/1234/status")).is_none());
243            assert!(find_namespace_boundary(Path::new("/proc/1234/exe")).is_none());
244            assert!(find_namespace_boundary(Path::new("/proc/1234/fd/0")).is_none());
245        }
246
247        #[test]
248        fn test_find_namespace_boundary_relative_path() {
249            assert!(find_namespace_boundary(Path::new("proc/1234/root")).is_none());
250        }
251
252        #[test]
253        fn test_find_namespace_boundary_invalid_pid() {
254            assert!(find_namespace_boundary(Path::new("/proc/abc/root")).is_none());
255            assert!(find_namespace_boundary(Path::new("/proc/123abc/root")).is_none());
256            assert!(find_namespace_boundary(Path::new("/proc//root")).is_none());
257        }
258
259        #[test]
260        fn test_canonicalize_proc_self_root() {
261            // /proc/self/root should return itself, not "/"
262            let result = canonicalize("/proc/self/root").expect("should succeed");
263            assert_eq!(result, PathBuf::from("/proc/self/root"));
264
265            // Contrast with std::fs::canonicalize which returns "/"
266            let std_result = std::fs::canonicalize("/proc/self/root").expect("should succeed");
267            assert_eq!(std_result, PathBuf::from("/"));
268
269            // They should be different!
270            assert_ne!(result, std_result);
271        }
272
273        #[test]
274        fn test_canonicalize_proc_self_root_subpath() {
275            // Test with a subpath that exists
276            let result = canonicalize("/proc/self/root/etc").expect("should succeed");
277            assert!(
278                result.starts_with("/proc/self/root"),
279                "should preserve /proc/self/root prefix, got: {:?}",
280                result
281            );
282        }
283
284        #[test]
285        fn test_canonicalize_normal_path() {
286            // Normal paths should behave like std::fs::canonicalize
287            let tmp = std::env::temp_dir();
288            let our_result = canonicalize(&tmp).expect("should succeed");
289            let std_result = std::fs::canonicalize(&tmp).expect("should succeed");
290            assert_eq!(our_result, std_result);
291        }
292
293        #[test]
294        fn test_canonicalize_proc_pid_root() {
295            use std::process;
296            let pid = process::id();
297            let proc_pid_root = format!("/proc/{}/root", pid);
298
299            let result = canonicalize(&proc_pid_root).expect("should succeed");
300            assert_eq!(result, PathBuf::from(&proc_pid_root));
301
302            // std would return "/"
303            let std_result = std::fs::canonicalize(&proc_pid_root).expect("should succeed");
304            assert_eq!(std_result, PathBuf::from("/"));
305        }
306
307        #[test]
308        fn test_canonicalize_proc_self_cwd() {
309            // /proc/self/cwd should also be preserved
310            let result = canonicalize("/proc/self/cwd").expect("should succeed");
311            assert_eq!(result, PathBuf::from("/proc/self/cwd"));
312        }
313
314        #[test]
315        fn test_canonicalize_nonexistent_file_under_namespace() {
316            // Non-existent file under valid namespace should return NotFound error
317            let result = canonicalize("/proc/self/root/this_file_definitely_does_not_exist_12345");
318            assert!(result.is_err());
319            let err = result.unwrap_err();
320            assert_eq!(err.kind(), io::ErrorKind::NotFound);
321        }
322
323        #[test]
324        fn test_canonicalize_nonexistent_pid() {
325            // Very high PID that almost certainly doesn't exist
326            let result = canonicalize("/proc/4294967295/root");
327            assert!(result.is_err());
328            let err = result.unwrap_err();
329            assert_eq!(err.kind(), io::ErrorKind::NotFound);
330        }
331
332        #[test]
333        fn test_canonicalize_with_dotdot_normalization() {
334            // Path with .. that should be normalized but stay within namespace
335            let result = canonicalize("/proc/self/root/etc/../etc/passwd");
336            // This should either succeed (if /etc/passwd exists) or fail with NotFound
337            // But if it succeeds, it must preserve the namespace prefix
338            if let Ok(path) = result {
339                assert!(
340                    path.starts_with("/proc/self/root"),
341                    "should preserve namespace prefix, got: {:?}",
342                    path
343                );
344            }
345        }
346
347        #[test]
348        fn test_canonicalize_with_dotdot_at_boundary() {
349            // Try to escape with .. - should still be contained
350            // /proc/self/root/../root/etc should resolve within namespace
351            let result = canonicalize("/proc/self/root/tmp/../etc");
352            if let Ok(path) = result {
353                assert!(
354                    path.starts_with("/proc/self/root"),
355                    "should preserve namespace prefix even with .., got: {:?}",
356                    path
357                );
358            }
359        }
360
361        #[test]
362        fn test_canonicalize_deep_nested_path() {
363            // Deep nested path under namespace
364            let result = canonicalize("/proc/self/root/usr/share/doc");
365            if let Ok(path) = result {
366                assert!(
367                    path.starts_with("/proc/self/root"),
368                    "should preserve namespace prefix for deep paths, got: {:?}",
369                    path
370                );
371            }
372        }
373
374        #[test]
375        fn test_canonicalize_trailing_slash() {
376            // Trailing slash should still work
377            let result = canonicalize("/proc/self/root/");
378            // Note: std::fs::canonicalize typically strips trailing slashes
379            if let Ok(path) = result {
380                assert!(
381                    path.starts_with("/proc/self/root"),
382                    "should handle trailing slash, got: {:?}",
383                    path
384                );
385            }
386        }
387
388        #[test]
389        fn test_canonicalize_thread_self() {
390            // /proc/thread-self/root should also work
391            let result = canonicalize("/proc/thread-self/root");
392            if let Ok(path) = result {
393                assert_eq!(path, PathBuf::from("/proc/thread-self/root"));
394            }
395            // Note: thread-self might not exist on all systems, so we allow failure
396        }
397
398        #[test]
399        fn test_canonicalize_symlink_resolution_within_namespace() {
400            // /etc/mtab is often a symlink - verify symlinks are resolved
401            // but namespace prefix is preserved
402            let result = canonicalize("/proc/self/root/etc/mtab");
403            if let Ok(path) = result {
404                assert!(
405                    path.starts_with("/proc/self/root"),
406                    "symlink resolution should preserve namespace, got: {:?}",
407                    path
408                );
409            }
410        }
411
412        #[test]
413        fn test_find_namespace_boundary_with_trailing_slash() {
414            // Path with trailing slash
415            let result = find_namespace_boundary(Path::new("/proc/1234/root/"));
416            assert!(result.is_some());
417            let (prefix, _remainder) = result.unwrap();
418            assert_eq!(prefix, PathBuf::from("/proc/1234/root"));
419            // Remainder might be empty or contain just a component depending on how trailing slash is parsed
420        }
421
422        #[test]
423        fn test_find_namespace_boundary_with_dots() {
424            // Path with . and .. components - these get normalized by Path
425            let result = find_namespace_boundary(Path::new("/proc/1234/root/./etc/../etc"));
426            assert!(result.is_some());
427            let (prefix, _remainder) = result.unwrap();
428            assert_eq!(prefix, PathBuf::from("/proc/1234/root"));
429            // Remainder will contain the unnormalized path components
430        }
431
432        #[test]
433        fn test_canonicalize_permission_denied() {
434            // Try to access another process's namespace without permission
435            // PID 1 is usually init and may have restricted access
436            let result = canonicalize("/proc/1/root/etc/shadow");
437            // This should either succeed or fail with PermissionDenied or NotFound
438            // depending on system configuration
439            if let Err(e) = result {
440                assert!(
441                    e.kind() == io::ErrorKind::PermissionDenied
442                        || e.kind() == io::ErrorKind::NotFound,
443                    "expected PermissionDenied or NotFound, got: {:?}",
444                    e.kind()
445                );
446            }
447        }
448
449        #[test]
450        fn test_canonicalize_pid_1_root() {
451            // PID 1 is always init/systemd - a real external process
452            // This is the realistic scenario: accessing another process's namespace
453            let result = canonicalize("/proc/1/root");
454
455            match result {
456                Ok(path) => {
457                    // If we have permission, the prefix MUST be preserved
458                    assert_eq!(
459                        path,
460                        PathBuf::from("/proc/1/root"),
461                        "must preserve /proc/1/root prefix"
462                    );
463
464                    // Verify std::fs::canonicalize would return "/" (the problem we're fixing)
465                    let std_result =
466                        std::fs::canonicalize("/proc/1/root").expect("std should also succeed");
467                    assert_eq!(std_result, PathBuf::from("/"), "std resolves to /");
468                }
469                Err(e) => {
470                    // Permission denied is acceptable - we're accessing another process
471                    assert!(
472                        e.kind() == io::ErrorKind::PermissionDenied
473                            || e.kind() == io::ErrorKind::NotFound,
474                        "expected PermissionDenied or NotFound, got: {:?}",
475                        e.kind()
476                    );
477                }
478            }
479        }
480
481        #[test]
482        fn test_canonicalize_pid_1_root_subpath() {
483            // Access a file through PID 1's namespace - realistic container scenario
484            let result = canonicalize("/proc/1/root/etc/hostname");
485
486            match result {
487                Ok(path) => {
488                    // Path MUST preserve the namespace boundary
489                    assert!(
490                        path.starts_with("/proc/1/root"),
491                        "must preserve /proc/1/root prefix, got: {:?}",
492                        path
493                    );
494                }
495                Err(e) => {
496                    // Permission denied or file not found is acceptable
497                    assert!(
498                        e.kind() == io::ErrorKind::PermissionDenied
499                            || e.kind() == io::ErrorKind::NotFound,
500                        "expected PermissionDenied or NotFound, got: {:?}",
501                        e.kind()
502                    );
503                }
504            }
505        }
506
507        #[test]
508        fn test_canonicalize_pid_1_cwd() {
509            // Test /proc/1/cwd - the working directory of init
510            let result = canonicalize("/proc/1/cwd");
511
512            match result {
513                Ok(path) => {
514                    assert_eq!(
515                        path,
516                        PathBuf::from("/proc/1/cwd"),
517                        "must preserve /proc/1/cwd"
518                    );
519                }
520                Err(e) => {
521                    assert!(
522                        e.kind() == io::ErrorKind::PermissionDenied
523                            || e.kind() == io::ErrorKind::NotFound,
524                        "expected PermissionDenied or NotFound, got: {:?}",
525                        e.kind()
526                    );
527                }
528            }
529        }
530
531        #[test]
532        fn test_self_vs_pid_equivalence() {
533            // /proc/self/root and /proc/{our_pid}/root should behave the same
534            use std::process;
535            let pid = process::id();
536
537            let self_result = canonicalize("/proc/self/root").expect("self should work");
538            let pid_result = canonicalize(format!("/proc/{}/root", pid)).expect("pid should work");
539
540            // Both should preserve their respective prefixes
541            assert_eq!(self_result, PathBuf::from("/proc/self/root"));
542            assert_eq!(pid_result, PathBuf::from(format!("/proc/{}/root", pid)));
543        }
544    }
545
546    #[cfg(not(target_os = "linux"))]
547    mod non_linux {
548        use super::*;
549
550        #[test]
551        fn test_canonicalize_is_std_on_non_linux() {
552            // On non-Linux, we just wrap std::fs::canonicalize
553            let tmp = std::env::temp_dir();
554            let our_result = canonicalize(&tmp).expect("should succeed");
555            let std_result = std::fs::canonicalize(&tmp).expect("should succeed");
556            // With dunce feature on Windows, our result is simplified but std returns UNC
557            #[cfg(all(feature = "dunce", windows))]
558            {
559                let our_str = our_result.to_string_lossy();
560                let std_str = std_result.to_string_lossy();
561                // dunce should simplify the path
562                assert!(!our_str.starts_with(r"\\?\"), "dunce should simplify path");
563                assert!(std_str.starts_with(r"\\?\"), "std returns UNC format");
564                // They should match except for the UNC prefix
565                assert_eq!(our_str.as_ref(), std_str.trim_start_matches(r"\\?\"));
566            }
567            // Without dunce (or on non-Windows), they should match exactly
568            #[cfg(not(all(feature = "dunce", windows)))]
569            {
570                assert_eq!(our_result, std_result);
571            }
572        }
573    }
574}