Skip to main content

strict_path/validator/
virtual_root.rs

1//! `VirtualRoot<Marker>` — the factory for `VirtualPath` values clamped to a virtual root.
2//!
3//! A `VirtualRoot` wraps a `PathBoundary` and maps all paths into a virtual namespace
4//! rooted at `"/"`. Traversal past the root is clamped (not rejected): `virtual_join("../../x")`
5//! resolves to `"/x"` rather than escaping the real filesystem boundary. This makes
6//! `VirtualRoot` safe to expose to untrusted input even without returning errors.
7use crate::path::virtual_path::VirtualPath;
8use crate::validator::path_history::PathHistory;
9use crate::PathBoundary;
10use crate::Result;
11use std::marker::PhantomData;
12use std::path::Path;
13
14/// Provide a user‑facing virtual root that produces `VirtualPath` values clamped to a boundary.
15#[derive(Clone)]
16#[must_use = "a VirtualRoot is validated and ready to enforce virtual path restrictions — call .virtual_join() to validate untrusted input, .into_virtualpath() to get the root path, or pass to functions that accept &VirtualRoot<Marker>"]
17#[doc(alias = "jail")]
18#[doc(alias = "chroot")]
19#[doc(alias = "sandbox")]
20#[doc(alias = "contain")]
21pub struct VirtualRoot<Marker = ()> {
22    pub(crate) root: PathBoundary<Marker>,
23    pub(crate) _marker: PhantomData<Marker>,
24}
25
26impl<Marker> VirtualRoot<Marker> {
27    // no extra constructors; use PathBoundary::virtualize() or VirtualRoot::try_new
28    /// Create a `VirtualRoot` from an existing directory.
29    ///
30    /// # Errors
31    ///
32    /// - `StrictPathError::InvalidRestriction`: Root invalid or cannot be canonicalized.
33    ///
34    /// # Examples
35    ///
36    /// ```rust
37    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
38    /// use strict_path::VirtualRoot;
39    /// let vroot = VirtualRoot::<()>::try_new("./data")?;
40    /// # Ok(())
41    /// # }
42    /// ```
43    #[must_use = "this returns a Result containing the validated VirtualRoot — handle the Result to detect invalid root directories"]
44    #[inline]
45    pub fn try_new<P: AsRef<Path>>(root_path: P) -> Result<Self> {
46        let root = PathBoundary::try_new(root_path)?;
47        Ok(Self {
48            root,
49            _marker: PhantomData,
50        })
51    }
52
53    /// Return filesystem metadata for the underlying root directory.
54    #[inline]
55    pub fn metadata(&self) -> std::io::Result<std::fs::Metadata> {
56        self.root.metadata()
57    }
58
59    /// Consume this virtual root and return the rooted `VirtualPath` ("/").
60    ///
61    /// # Errors
62    ///
63    /// - `StrictPathError::PathResolutionError`: Canonicalization fails (root removed or inaccessible).
64    /// - `StrictPathError::PathEscapesBoundary`: Root moved outside the boundary between checks.
65    ///
66    /// # Examples
67    ///
68    /// ```rust
69    /// # use strict_path::{VirtualPath, VirtualRoot};
70    /// # let root = std::env::temp_dir().join("into-virtualpath-example");
71    /// # std::fs::create_dir_all(&root)?;
72    /// let vroot: VirtualRoot = VirtualRoot::try_new(&root)?;
73    /// let root_virtual: VirtualPath = vroot.into_virtualpath()?;
74    /// assert_eq!(root_virtual.virtualpath_display().to_string(), "/");
75    /// # std::fs::remove_dir_all(&root)?;
76    /// # Ok::<_, Box<dyn std::error::Error>>(())
77    /// ```
78    #[must_use = "into_virtualpath() consumes the VirtualRoot — use the returned VirtualPath for virtual path operations"]
79    #[inline]
80    pub fn into_virtualpath(self) -> Result<VirtualPath<Marker>> {
81        let strict_root = self.root.into_strictpath()?;
82        Ok(strict_root.virtualize())
83    }
84
85    /// Consume this virtual root and substitute a new marker type.
86    ///
87    /// Mirrors [`crate::PathBoundary::change_marker`], [`crate::StrictPath::change_marker`], and
88    /// [`crate::VirtualPath::change_marker`]. Use this when encoding proven authorization
89    /// into the type system (e.g., after validating a user's permissions). The
90    /// consumption makes marker changes explicit during code review.
91    ///
92    /// # Examples
93    ///
94    /// ```rust
95    /// # use strict_path::VirtualRoot;
96    /// # let root_dir = std::env::temp_dir().join("vroot-change-marker-example");
97    /// # std::fs::create_dir_all(&root_dir)?;
98    /// struct UserFiles;
99    /// struct ReadOnly;
100    /// struct ReadWrite;
101    ///
102    /// let read_root: VirtualRoot<(UserFiles, ReadOnly)> = VirtualRoot::try_new(&root_dir)?;
103    ///
104    /// // After authorization check...
105    /// let write_root: VirtualRoot<(UserFiles, ReadWrite)> = read_root.change_marker();
106    /// # std::fs::remove_dir_all(&root_dir)?;
107    /// # Ok::<_, Box<dyn std::error::Error>>(())
108    /// ```
109    #[must_use = "change_marker() consumes self — the original VirtualRoot is moved; use the returned VirtualRoot<NewMarker>"]
110    #[inline]
111    pub fn change_marker<NewMarker>(self) -> VirtualRoot<NewMarker> {
112        let VirtualRoot { root, .. } = self;
113
114        VirtualRoot {
115            root: root.change_marker(),
116            _marker: PhantomData,
117        }
118    }
119
120    /// Create a symbolic link at `link_path` pointing to this root's underlying directory.
121    ///
122    /// `link_path` is interpreted in the virtual dimension and resolved via `virtual_join()`
123    /// so that absolute virtual paths ("/links/a") are clamped within this virtual root and
124    /// relative paths are resolved relative to the virtual root.
125    pub fn virtual_symlink<P: AsRef<Path>>(&self, link_path: P) -> std::io::Result<()> {
126        // Resolve the link location in virtual space first (clamps/anchors under this root)
127        let link_ref = link_path.as_ref();
128        let validated_link = self.virtual_join(link_ref).map_err(std::io::Error::other)?;
129
130        // Obtain the strict target for the root directory
131        let root = self
132            .root
133            .clone()
134            .into_strictpath()
135            .map_err(std::io::Error::other)?;
136
137        root.strict_symlink(validated_link.as_unvirtual().path())
138    }
139
140    /// Create a hard link at `link_path` pointing to this root's underlying directory.
141    ///
142    /// The link location is resolved via `virtual_join()` to clamp/anchor within this root.
143    /// Note: Most platforms forbid directory hard links; expect an error from the OS.
144    pub fn virtual_hard_link<P: AsRef<Path>>(&self, link_path: P) -> std::io::Result<()> {
145        let link_ref = link_path.as_ref();
146        let validated_link = self.virtual_join(link_ref).map_err(std::io::Error::other)?;
147
148        let root = self
149            .root
150            .clone()
151            .into_strictpath()
152            .map_err(std::io::Error::other)?;
153
154        root.strict_hard_link(validated_link.as_unvirtual().path())
155    }
156
157    /// Create a Windows NTFS directory junction at `link_path` pointing to this virtual root's directory.
158    ///
159    /// - Windows-only and behind the `junctions` feature.
160    #[cfg(all(windows, feature = "junctions"))]
161    pub fn virtual_junction<P: AsRef<Path>>(&self, link_path: P) -> std::io::Result<()> {
162        let link_ref = link_path.as_ref();
163        let validated_link = self.virtual_join(link_ref).map_err(std::io::Error::other)?;
164
165        let root = self
166            .root
167            .clone()
168            .into_strictpath()
169            .map_err(std::io::Error::other)?;
170
171        root.strict_junction(validated_link.as_unvirtual().path())
172    }
173
174    /// Read directory entries at the virtual root (discovery). Re‑join names through virtual/strict APIs before I/O.
175    #[inline]
176    pub fn read_dir(&self) -> std::io::Result<std::fs::ReadDir> {
177        self.root.read_dir()
178    }
179
180    /// Iterate directory entries at the virtual root, yielding validated `VirtualPath` values.
181    ///
182    /// Unlike `read_dir()` which returns raw `std::fs::DirEntry` values requiring manual
183    /// re-validation, this method yields `VirtualPath` entries directly. Each entry is
184    /// automatically validated through `virtual_join()` so you can use it immediately
185    /// for I/O operations without additional validation.
186    ///
187    /// # Examples
188    ///
189    /// ```rust
190    /// use strict_path::VirtualRoot;
191    ///
192    /// # let temp = tempfile::tempdir()?;
193    /// let vroot: VirtualRoot = VirtualRoot::try_new(temp.path())?;
194    /// # vroot.virtual_join("file.txt")?.write("test")?;
195    ///
196    /// // Auto-validated iteration - no manual re-join needed!
197    /// for entry in vroot.virtual_read_dir()? {
198    ///     let child = entry?;
199    ///     println!("Virtual: {}", child.virtualpath_display());
200    /// }
201    /// # Ok::<_, Box<dyn std::error::Error>>(())
202    /// ```
203    #[inline]
204    pub fn virtual_read_dir(&self) -> std::io::Result<VirtualRootReadDir<'_, Marker>> {
205        Ok(VirtualRootReadDir {
206            inner: self.root.read_dir()?,
207            vroot: self,
208        })
209    }
210
211    /// Remove the underlying root directory (non‑recursive); fails if not empty.
212    #[inline]
213    pub fn remove_dir(&self) -> std::io::Result<()> {
214        self.root.remove_dir()
215    }
216
217    /// Recursively remove the underlying root directory and all its contents.
218    #[inline]
219    pub fn remove_dir_all(&self) -> std::io::Result<()> {
220        self.root.remove_dir_all()
221    }
222
223    /// Ensure the directory exists (create if missing), then return a `VirtualRoot`.
224    ///
225    /// # Examples
226    ///
227    /// Uses `AsRef<Path>` for maximum ergonomics, including direct `TempDir` support for clean shadowing patterns:
228    /// ```rust
229    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
230    /// use strict_path::VirtualRoot;
231    /// let vroot = VirtualRoot::<()>::try_new_create("./data")?;
232    /// # Ok(())
233    /// # }
234    /// ```
235    #[must_use = "this returns a Result containing the validated VirtualRoot — handle the Result to detect invalid root directories"]
236    #[inline]
237    pub fn try_new_create<P: AsRef<Path>>(root_path: P) -> Result<Self> {
238        let root = PathBoundary::try_new_create(root_path)?;
239        Ok(Self {
240            root,
241            _marker: PhantomData,
242        })
243    }
244
245    /// Join a candidate path to this virtual root, producing a clamped `VirtualPath`.
246    ///
247    /// This is the security gateway for virtual paths. Absolute paths (starting with `"/"`) are
248    /// automatically clamped to the virtual root, ensuring paths cannot escape the sandbox.
249    /// For example, `"/etc/config"` becomes `vroot/etc/config`, and traversal attempts like
250    /// `"../../../../etc/passwd"` are clamped to `vroot/etc/passwd`. This clamping behavior is
251    /// what makes the `virtual_` dimension safe for user-facing operations.
252    ///
253    /// # Errors
254    ///
255    /// - `StrictPathError::PathResolutionError`, `StrictPathError::PathEscapesBoundary`.
256    ///
257    /// # Examples
258    ///
259    /// ```rust
260    /// # use strict_path::VirtualRoot;
261    /// # let td = tempfile::tempdir().unwrap();
262    /// let vroot: VirtualRoot = VirtualRoot::try_new_create(td.path())?;
263    ///
264    /// // Absolute paths are clamped to virtual root, not system root
265    /// let user_input_abs = "/etc/config"; // Untrusted input
266    /// let path1 = vroot.virtual_join(user_input_abs)?;
267    /// assert_eq!(path1.virtualpath_display().to_string(), "/etc/config");
268    ///
269    /// // Traversal attempts are also clamped
270    /// let attack_input = "../../../etc/passwd"; // Untrusted input
271    /// let path2 = vroot.virtual_join(attack_input)?;
272    /// assert_eq!(path2.virtualpath_display().to_string(), "/etc/passwd");
273    ///
274    /// // Both paths are safely within the virtual root on the actual filesystem
275    /// # Ok::<(), Box<dyn std::error::Error>>(())
276    /// ```
277    #[must_use = "virtual_join() validates untrusted input against the virtual root — always handle the Result to detect escape attempts"]
278    #[inline]
279    pub fn virtual_join<P: AsRef<Path>>(&self, candidate_path: P) -> Result<VirtualPath<Marker>> {
280        // 1) Anchor in virtual space (clamps virtual root and resolves relative parts)
281        let user_candidate = candidate_path.as_ref().to_path_buf();
282        let anchored = PathHistory::new(user_candidate).canonicalize_anchored(&self.root)?;
283
284        // 2) Boundary-check once against the PathBoundary's canonicalized root (no re-canonicalization)
285        let validated = anchored.boundary_check(self.root.stated_path())?;
286
287        // 3) Construct a StrictPath directly and then virtualize
288        let jp = crate::path::strict_path::StrictPath::new(
289            std::sync::Arc::new(self.root.clone()),
290            validated,
291        );
292        Ok(jp.virtualize())
293    }
294
295    /// Returns the underlying path boundary root as a system path.
296    #[inline]
297    pub(crate) fn path(&self) -> &Path {
298        self.root.path()
299    }
300
301    /// Return the virtual root path as `&OsStr` for unavoidable third-party `AsRef<Path>` interop.
302    #[must_use = "pass interop_path() directly to third-party APIs requiring AsRef<Path> — never wrap it in Path::new() or PathBuf::from() as that defeats boundary safety"]
303    #[inline]
304    pub fn interop_path(&self) -> &std::ffi::OsStr {
305        self.root.interop_path()
306    }
307
308    /// Returns true if the underlying path boundary root exists.
309    #[must_use]
310    #[inline]
311    pub fn exists(&self) -> bool {
312        self.root.exists()
313    }
314
315    /// Borrow the underlying `PathBoundary`.
316    #[must_use = "as_unvirtual() borrows the underlying PathBoundary — use it for strict operations or pass to functions accepting &PathBoundary<Marker>"]
317    #[inline]
318    pub fn as_unvirtual(&self) -> &PathBoundary<Marker> {
319        &self.root
320    }
321
322    /// Consume this `VirtualRoot` and return the underlying `PathBoundary` (symmetry with `virtualize`).
323    #[must_use = "unvirtual() consumes self — use the returned PathBoundary for strict path operations, or prefer .as_unvirtual() to borrow without consuming"]
324    #[inline]
325    pub fn unvirtual(self) -> PathBoundary<Marker> {
326        self.root
327    }
328
329    // OS Standard Directory Constructors
330    //
331    // Creates virtual roots in OS standard directories following platform conventions.
332    // Applications see clean virtual paths ("/config.toml") while the system manages
333    // the actual location (e.g., "~/.config/myapp/config.toml").
334}
335
336/// Display shows "/": The real system path must never appear in user-facing output
337/// (logs, API responses, error messages).  Showing "/" reinforces that VirtualRoot
338/// represents a virtual namespace root, not a concrete filesystem location.
339impl<Marker> std::fmt::Display for VirtualRoot<Marker> {
340    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
341        write!(f, "/")
342    }
343}
344
345impl<Marker> std::fmt::Debug for VirtualRoot<Marker> {
346    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
347        f.debug_struct("VirtualRoot")
348            .field("root", &self.path())
349            .field("marker", &std::any::type_name::<Marker>())
350            .finish()
351    }
352}
353
354impl<Marker> Eq for VirtualRoot<Marker> {}
355
356impl<M1, M2> PartialEq<VirtualRoot<M2>> for VirtualRoot<M1> {
357    #[inline]
358    fn eq(&self, other: &VirtualRoot<M2>) -> bool {
359        self.path() == other.path()
360    }
361}
362
363impl<Marker> std::hash::Hash for VirtualRoot<Marker> {
364    #[inline]
365    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
366        self.path().hash(state);
367    }
368}
369
370impl<Marker> PartialOrd for VirtualRoot<Marker> {
371    #[inline]
372    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
373        Some(self.cmp(other))
374    }
375}
376
377impl<Marker> Ord for VirtualRoot<Marker> {
378    #[inline]
379    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
380        self.path().cmp(other.path())
381    }
382}
383
384impl<M1, M2> PartialEq<crate::PathBoundary<M2>> for VirtualRoot<M1> {
385    #[inline]
386    fn eq(&self, other: &crate::PathBoundary<M2>) -> bool {
387        self.path() == other.path()
388    }
389}
390
391/// compare against "/": VirtualRoot's public identity is the virtual namespace root.
392/// Comparing against the real system path would leak implementation details and break the
393/// abstraction — callers should never need to know the underlying directory.
394impl<Marker> PartialEq<std::path::Path> for VirtualRoot<Marker> {
395    #[inline]
396    fn eq(&self, other: &std::path::Path) -> bool {
397        // Compare as virtual root path (always "/")
398        // VirtualRoot represents the virtual "/" regardless of underlying system path
399        let other_str = other.to_string_lossy();
400
401        #[cfg(windows)]
402        let other_normalized = other_str.replace('\\', "/");
403        #[cfg(not(windows))]
404        let other_normalized = other_str.to_string();
405
406        let normalized_other = if other_normalized.starts_with('/') {
407            other_normalized
408        } else {
409            format!("/{other_normalized}")
410        };
411
412        "/" == normalized_other
413    }
414}
415
416impl<Marker> PartialEq<std::path::PathBuf> for VirtualRoot<Marker> {
417    #[inline]
418    fn eq(&self, other: &std::path::PathBuf) -> bool {
419        self.eq(other.as_path())
420    }
421}
422
423impl<Marker> PartialEq<&std::path::Path> for VirtualRoot<Marker> {
424    #[inline]
425    fn eq(&self, other: &&std::path::Path) -> bool {
426        self.eq(*other)
427    }
428}
429
430impl<Marker> PartialOrd<std::path::Path> for VirtualRoot<Marker> {
431    #[inline]
432    fn partial_cmp(&self, other: &std::path::Path) -> Option<std::cmp::Ordering> {
433        // Compare as virtual root path (always "/")
434        let other_str = other.to_string_lossy();
435
436        // Handle empty path specially - "/" is greater than ""
437        if other_str.is_empty() {
438            return Some(std::cmp::Ordering::Greater);
439        }
440
441        #[cfg(windows)]
442        let other_normalized = other_str.replace('\\', "/");
443        #[cfg(not(windows))]
444        let other_normalized = other_str.to_string();
445
446        let normalized_other = if other_normalized.starts_with('/') {
447            other_normalized
448        } else {
449            format!("/{other_normalized}")
450        };
451
452        Some("/".cmp(&normalized_other))
453    }
454}
455
456impl<Marker> PartialOrd<&std::path::Path> for VirtualRoot<Marker> {
457    #[inline]
458    fn partial_cmp(&self, other: &&std::path::Path) -> Option<std::cmp::Ordering> {
459        self.partial_cmp(*other)
460    }
461}
462
463impl<Marker> PartialOrd<std::path::PathBuf> for VirtualRoot<Marker> {
464    #[inline]
465    fn partial_cmp(&self, other: &std::path::PathBuf) -> Option<std::cmp::Ordering> {
466        self.partial_cmp(other.as_path())
467    }
468}
469
470impl<Marker> std::str::FromStr for VirtualRoot<Marker> {
471    type Err = crate::StrictPathError;
472
473    /// Forwards to [`try_new_create`](Self::try_new_create): creates the
474    /// target directory if missing, then canonicalizes and validates it as a
475    /// directory.
476    ///
477    /// Untrusted per-request paths (archive entries, user-supplied virtual
478    /// paths) are not `FromStr` input — validate those via
479    /// [`virtual_join`](Self::virtual_join) on a pre-constructed root.
480    ///
481    /// ```rust
482    /// # use strict_path::VirtualRoot;
483    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
484    /// # let temp_dir = tempfile::tempdir()?;
485    /// # let p = temp_dir.path().join("sandbox");
486    /// # let p = p.to_string_lossy().into_owned();
487    /// let vroot: VirtualRoot<()> = p.parse()?;
488    /// assert!(vroot.exists());
489    /// # Ok(())
490    /// # }
491    /// ```
492    #[inline]
493    fn from_str(path: &str) -> std::result::Result<Self, Self::Err> {
494        Self::try_new_create(path)
495    }
496}
497
498// ============================================================
499// VirtualRootReadDir — Iterator for validated virtual directory entries
500// ============================================================
501
502/// Iterator over directory entries that yields validated `VirtualPath` values.
503///
504/// Created by `VirtualRoot::virtual_read_dir()`. Each iteration automatically validates
505/// the directory entry through `virtual_join()`, so you get `VirtualPath` values directly
506/// instead of raw `std::fs::DirEntry` that would require manual re-validation.
507///
508/// # Examples
509///
510/// ```rust
511/// # use strict_path::VirtualRoot;
512/// # let temp = tempfile::tempdir()?;
513/// let vroot: VirtualRoot = VirtualRoot::try_new(temp.path())?;
514/// # vroot.virtual_join("readme.md")?.write("# Docs")?;
515/// for entry in vroot.virtual_read_dir()? {
516///     let child = entry?;
517///     if child.is_file() {
518///         println!("Virtual: {}", child.virtualpath_display());
519///     }
520/// }
521/// # Ok::<_, Box<dyn std::error::Error>>(())
522/// ```
523pub struct VirtualRootReadDir<'a, Marker> {
524    inner: std::fs::ReadDir,
525    vroot: &'a VirtualRoot<Marker>,
526}
527
528impl<Marker> std::fmt::Debug for VirtualRootReadDir<'_, Marker> {
529    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
530        f.debug_struct("VirtualRootReadDir")
531            .field("vroot", &"/")
532            .finish_non_exhaustive()
533    }
534}
535
536impl<Marker: Clone> Iterator for VirtualRootReadDir<'_, Marker> {
537    type Item = std::io::Result<crate::path::virtual_path::VirtualPath<Marker>>;
538
539    fn next(&mut self) -> Option<Self::Item> {
540        match self.inner.next()? {
541            Ok(entry) => {
542                let file_name = entry.file_name();
543                match self.vroot.virtual_join(file_name) {
544                    Ok(virtual_path) => Some(Ok(virtual_path)),
545                    Err(e) => Some(Err(std::io::Error::new(std::io::ErrorKind::InvalidData, e))),
546                }
547            }
548            Err(e) => Some(Err(e)),
549        }
550    }
551}