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(¤t_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}