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`). This makes paths more readable but may
44//!   lose some Windows path capabilities. Enable with `features = ["dunce"]` in your `Cargo.toml`.
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!("namespace path does not exist: {}", namespace_prefix.display()),
91            ));
92        }
93
94        if remainder.as_os_str().is_empty() {
95            // Path IS the namespace boundary (e.g., "/proc/1234/root")
96            Ok(namespace_prefix)
97        } else {
98            // Path goes through namespace boundary (e.g., "/proc/1234/root/etc/passwd")
99            // Canonicalize the full path, then re-attach the namespace prefix
100            let full_path = namespace_prefix.join(&remainder);
101            
102            // Use std::fs::canonicalize on the full path - this will traverse
103            // through /proc/PID/root correctly, but return a path without the prefix
104            let canonicalized = std::fs::canonicalize(&full_path)?;
105            
106            // The result will be something like "/etc/passwd" (the container's view)
107            // We need to re-attach the namespace prefix
108            Ok(namespace_prefix.join(canonicalized.strip_prefix("/").unwrap_or(&canonicalized)))
109        }
110    } else {
111        // Normal path - use std::fs::canonicalize directly
112        std::fs::canonicalize(path)
113    }
114}
115
116#[cfg(not(target_os = "linux"))]
117fn canonicalize_impl(path: &Path) -> io::Result<PathBuf> {
118    // On non-Linux platforms, just use std::fs::canonicalize
119    #[cfg(all(feature = "dunce", windows))]
120    {
121        dunce::canonicalize(path)
122    }
123    #[cfg(not(all(feature = "dunce", windows)))]
124    {
125        std::fs::canonicalize(path)
126    }
127}
128
129/// Find a `/proc/PID/root` or `/proc/PID/cwd` namespace boundary in the path.
130///
131/// Returns `Some((namespace_prefix, remainder))` if found, where:
132/// - `namespace_prefix` is the boundary path (e.g., `/proc/1234/root`)
133/// - `remainder` is the path after the boundary (e.g., `etc/passwd`)
134///
135/// Returns `None` if the path doesn't contain a namespace boundary.
136#[cfg(target_os = "linux")]
137fn find_namespace_boundary(path: &Path) -> Option<(PathBuf, PathBuf)> {
138    let mut components = path.components();
139
140    // Must start with root "/"
141    if components.next() != Some(Component::RootDir) {
142        return None;
143    }
144
145    // Next must be "proc"
146    match components.next() {
147        Some(Component::Normal(s)) if s == "proc" => {}
148        _ => return None,
149    }
150
151    // Next must be a PID (digits), "self", or "thread-self"
152    let pid_component = match components.next() {
153        Some(Component::Normal(s)) => s,
154        _ => return None,
155    };
156
157    let pid_str = pid_component.to_string_lossy();
158    let is_valid_pid = pid_str == "self"
159        || pid_str == "thread-self"
160        || (!pid_str.is_empty() && pid_str.chars().all(|c| c.is_ascii_digit()));
161
162    if !is_valid_pid {
163        return None;
164    }
165
166    // Next must be "root" or "cwd"
167    let ns_type = match components.next() {
168        Some(Component::Normal(s)) if s == "root" || s == "cwd" => s,
169        _ => return None,
170    };
171
172    // Build the namespace prefix: /proc/{pid}/{root|cwd}
173    let mut prefix = PathBuf::from("/proc");
174    prefix.push(pid_component);
175    prefix.push(ns_type);
176
177    // Collect remaining components as the remainder
178    let remainder: PathBuf = components.collect();
179
180    Some((prefix, remainder))
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[cfg(target_os = "linux")]
188    mod linux {
189        use super::*;
190
191        #[test]
192        fn test_find_namespace_boundary_proc_pid_root() {
193            let (prefix, remainder) =
194                find_namespace_boundary(Path::new("/proc/1234/root/etc/passwd")).unwrap();
195            assert_eq!(prefix, PathBuf::from("/proc/1234/root"));
196            assert_eq!(remainder, PathBuf::from("etc/passwd"));
197        }
198
199        #[test]
200        fn test_find_namespace_boundary_proc_pid_cwd() {
201            let (prefix, remainder) =
202                find_namespace_boundary(Path::new("/proc/5678/cwd/some/file.txt")).unwrap();
203            assert_eq!(prefix, PathBuf::from("/proc/5678/cwd"));
204            assert_eq!(remainder, PathBuf::from("some/file.txt"));
205        }
206
207        #[test]
208        fn test_find_namespace_boundary_proc_self_root() {
209            let (prefix, remainder) =
210                find_namespace_boundary(Path::new("/proc/self/root/etc/passwd")).unwrap();
211            assert_eq!(prefix, PathBuf::from("/proc/self/root"));
212            assert_eq!(remainder, PathBuf::from("etc/passwd"));
213        }
214
215        #[test]
216        fn test_find_namespace_boundary_proc_thread_self_root() {
217            let (prefix, remainder) =
218                find_namespace_boundary(Path::new("/proc/thread-self/root/app/config")).unwrap();
219            assert_eq!(prefix, PathBuf::from("/proc/thread-self/root"));
220            assert_eq!(remainder, PathBuf::from("app/config"));
221        }
222
223        #[test]
224        fn test_find_namespace_boundary_just_prefix() {
225            let (prefix, remainder) =
226                find_namespace_boundary(Path::new("/proc/1234/root")).unwrap();
227            assert_eq!(prefix, PathBuf::from("/proc/1234/root"));
228            assert_eq!(remainder, PathBuf::from(""));
229        }
230
231        #[test]
232        fn test_find_namespace_boundary_normal_path() {
233            assert!(find_namespace_boundary(Path::new("/home/user/file.txt")).is_none());
234        }
235
236        #[test]
237        fn test_find_namespace_boundary_proc_but_not_namespace() {
238            // /proc/1234/status is NOT a namespace boundary
239            assert!(find_namespace_boundary(Path::new("/proc/1234/status")).is_none());
240            assert!(find_namespace_boundary(Path::new("/proc/1234/exe")).is_none());
241            assert!(find_namespace_boundary(Path::new("/proc/1234/fd/0")).is_none());
242        }
243
244        #[test]
245        fn test_find_namespace_boundary_relative_path() {
246            assert!(find_namespace_boundary(Path::new("proc/1234/root")).is_none());
247        }
248
249        #[test]
250        fn test_find_namespace_boundary_invalid_pid() {
251            assert!(find_namespace_boundary(Path::new("/proc/abc/root")).is_none());
252            assert!(find_namespace_boundary(Path::new("/proc/123abc/root")).is_none());
253            assert!(find_namespace_boundary(Path::new("/proc//root")).is_none());
254        }
255
256        #[test]
257        fn test_canonicalize_proc_self_root() {
258            // /proc/self/root should return itself, not "/"
259            let result = canonicalize("/proc/self/root").expect("should succeed");
260            assert_eq!(result, PathBuf::from("/proc/self/root"));
261
262            // Contrast with std::fs::canonicalize which returns "/"
263            let std_result = std::fs::canonicalize("/proc/self/root").expect("should succeed");
264            assert_eq!(std_result, PathBuf::from("/"));
265
266            // They should be different!
267            assert_ne!(result, std_result);
268        }
269
270        #[test]
271        fn test_canonicalize_proc_self_root_subpath() {
272            // Test with a subpath that exists
273            let result = canonicalize("/proc/self/root/etc").expect("should succeed");
274            assert!(
275                result.starts_with("/proc/self/root"),
276                "should preserve /proc/self/root prefix, got: {:?}",
277                result
278            );
279        }
280
281        #[test]
282        fn test_canonicalize_normal_path() {
283            // Normal paths should behave like std::fs::canonicalize
284            let tmp = std::env::temp_dir();
285            let our_result = canonicalize(&tmp).expect("should succeed");
286            let std_result = std::fs::canonicalize(&tmp).expect("should succeed");
287            assert_eq!(our_result, std_result);
288        }
289
290        #[test]
291        fn test_canonicalize_proc_pid_root() {
292            use std::process;
293            let pid = process::id();
294            let proc_pid_root = format!("/proc/{}/root", pid);
295
296            let result = canonicalize(&proc_pid_root).expect("should succeed");
297            assert_eq!(result, PathBuf::from(&proc_pid_root));
298
299            // std would return "/"
300            let std_result = std::fs::canonicalize(&proc_pid_root).expect("should succeed");
301            assert_eq!(std_result, PathBuf::from("/"));
302        }
303    }
304
305    #[cfg(not(target_os = "linux"))]
306    mod non_linux {
307        use super::*;
308
309        #[test]
310        fn test_canonicalize_is_std_on_non_linux() {
311            // On non-Linux, we just wrap std::fs::canonicalize
312            let tmp = std::env::temp_dir();
313            let our_result = canonicalize(&tmp).expect("should succeed");
314            let std_result = std::fs::canonicalize(&tmp).expect("should succeed");
315            // With dunce feature on Windows, our result is simplified but std returns UNC
316        #[cfg(all(feature = "dunce", windows))]
317        {
318            let our_str = our_result.to_string_lossy();
319            let std_str = std_result.to_string_lossy();
320            // dunce should simplify the path
321            assert!(!our_str.starts_with(r"\\?\"), "dunce should simplify path");
322            assert!(std_str.starts_with(r"\\?\"), "std returns UNC format");
323            // They should match except for the UNC prefix
324            assert_eq!(our_str.as_ref(), std_str.trim_start_matches(r"\\?\"));
325        }
326        // Without dunce (or on non-Windows), they should match exactly
327        #[cfg(not(all(feature = "dunce", windows)))]
328        {
329            assert_eq!(our_result, std_result);
330        }
331        }
332    }
333}