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(¤t) {
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(¤t) {
296 Some(current)
297 } else {
298 None
299 });
300 }
301
302 // Read where the symlink points
303 let target = std::fs::read_link(¤t)?;
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}