Skip to main content

strict_path/path/virtual_path/
mod.rs

1//! `VirtualPath<Marker>` — a user-facing path clamped to a virtual root.
2//!
3//! `VirtualPath` wraps a `StrictPath` and adds a virtual path component rooted at `"/"`.
4//! The virtual path is what users see (e.g., `"/uploads/logo.png"`); the real system path
5//! is never exposed. Use `virtualpath_display()` for safe user-visible output and
6//! `as_unvirtual()` to obtain the underlying `StrictPath` for system-facing I/O.
7mod display;
8mod fs;
9mod iter;
10mod links;
11mod traits;
12
13pub use display::VirtualPathDisplay;
14pub use iter::VirtualReadDir;
15
16use crate::error::StrictPathError;
17use crate::path::strict_path::StrictPath;
18use crate::validator::path_history::{Canonicalized, PathHistory};
19use crate::PathBoundary;
20use crate::Result;
21use std::ffi::OsStr;
22use std::path::{Path, PathBuf};
23
24/// Hold a user‑facing path clamped to a virtual root (`"/"`) over a `PathBoundary`.
25///
26/// `virtualpath_display()` shows rooted, forward‑slashed paths (e.g., `"/a/b.txt"`).
27/// Use virtual manipulation methods to compose paths while preserving clamping, then convert to
28/// `StrictPath` with `unvirtual()` for system‑facing I/O.
29#[derive(Clone)]
30#[must_use = "a VirtualPath is boundary-validated and user-facing — use .virtualpath_display() for safe user output, .virtual_join() to compose paths, or .as_unvirtual() for system-facing I/O"]
31#[doc(alias = "jailed_path")]
32#[doc(alias = "sandboxed_path")]
33#[doc(alias = "contained_path")]
34pub struct VirtualPath<Marker = ()> {
35    pub(crate) inner: StrictPath<Marker>,
36    pub(crate) virtual_path: PathBuf,
37}
38
39/// Re-validate a canonicalized path against the boundary after virtual-space manipulation.
40///
41/// Every virtual mutation (join, parent, with_*) produces a candidate in virtual space that
42/// must be re-anchored and boundary-checked before it becomes a real `StrictPath`. This
43/// centralizes that re-validation so each caller does not duplicate the check.
44#[inline]
45fn clamp<Marker, H>(
46    restriction: &PathBoundary<Marker>,
47    anchored: PathHistory<(H, Canonicalized)>,
48) -> crate::Result<crate::path::strict_path::StrictPath<Marker>> {
49    restriction.strict_join(anchored.into_inner())
50}
51
52impl<Marker> VirtualPath<Marker> {
53    /// Create the virtual root (`"/"`) for the given filesystem root.
54    #[must_use = "this returns a Result containing the validated VirtualPath — handle the Result to detect invalid roots"]
55    pub fn with_root<P: AsRef<Path>>(root: P) -> Result<Self> {
56        let vroot = crate::validator::virtual_root::VirtualRoot::try_new(root)?;
57        vroot.into_virtualpath()
58    }
59
60    /// Create the virtual root, creating the filesystem root if missing.
61    #[must_use = "this returns a Result containing the validated VirtualPath — handle the Result to detect invalid roots"]
62    pub fn with_root_create<P: AsRef<Path>>(root: P) -> Result<Self> {
63        let vroot = crate::validator::virtual_root::VirtualRoot::try_new_create(root)?;
64        vroot.into_virtualpath()
65    }
66
67    #[inline]
68    pub(crate) fn new(strict_path: StrictPath<Marker>) -> Self {
69        /// Derive the user-facing virtual path from the real system path and boundary.
70        ///
71        /// WHY: Users must never see the real host path (leaks tenant IDs, infra details).
72        /// This function strips the boundary prefix from the system path and sanitizes
73        /// the remaining components to produce a safe, rooted virtual view.
74        fn compute_virtual<Marker>(
75            system_path: &std::path::Path,
76            restriction: &crate::PathBoundary<Marker>,
77        ) -> std::path::PathBuf {
78            use std::path::Component;
79
80            // WHY: Windows canonicalization adds a `\\?\` verbatim prefix that breaks
81            // `strip_prefix` comparisons between system_path and jail_norm. `dunce`
82            // strips it via `std::path::Prefix` matching (no lossy UTF-8 round-trip)
83            // and declines to strip when doing so would be unsafe (reserved device
84            // names, >MAX_PATH, trailing dots). On non-Windows it is a no-op.
85            #[cfg(windows)]
86            fn strip_verbatim(p: &std::path::Path) -> std::path::PathBuf {
87                dunce::simplified(p).to_path_buf()
88            }
89
90            #[cfg(not(windows))]
91            fn strip_verbatim(p: &std::path::Path) -> std::path::PathBuf {
92                p.to_path_buf()
93            }
94
95            let system_norm = strip_verbatim(system_path);
96            let jail_norm = strip_verbatim(restriction.path());
97
98            // Fast path: strip the boundary prefix directly. This works when both
99            // paths share a common prefix after verbatim normalization.
100            // Raw components are stored; sanitization happens at display time in
101            // VirtualPathDisplay::fmt so that virtual_join navigation remains correct.
102            if let Ok(stripped) = system_norm.strip_prefix(&jail_norm) {
103                let mut cleaned = std::path::PathBuf::new();
104                for comp in stripped.components() {
105                    if let Component::Normal(name) = comp {
106                        cleaned.push(name);
107                    }
108                }
109                return cleaned;
110            }
111
112            // Fallback: when strip_prefix fails (e.g., case differences on
113            // Windows, or UNC vs local prefix mismatch), walk components
114            // manually and skip the shared prefix with platform-aware comparison.
115            let mut strictpath_comps: Vec<_> = system_norm
116                .components()
117                .filter(|c| !matches!(c, Component::Prefix(_) | Component::RootDir))
118                .collect();
119            let mut boundary_comps: Vec<_> = jail_norm
120                .components()
121                .filter(|c| !matches!(c, Component::Prefix(_) | Component::RootDir))
122                .collect();
123
124            #[cfg(windows)]
125            fn comp_eq(a: &Component, b: &Component) -> bool {
126                match (a, b) {
127                    (Component::Normal(x), Component::Normal(y)) => {
128                        x.to_string_lossy().to_ascii_lowercase()
129                            == y.to_string_lossy().to_ascii_lowercase()
130                    }
131                    _ => false,
132                }
133            }
134
135            #[cfg(not(windows))]
136            fn comp_eq(a: &Component, b: &Component) -> bool {
137                a == b
138            }
139
140            while !strictpath_comps.is_empty()
141                && !boundary_comps.is_empty()
142                && comp_eq(&strictpath_comps[0], &boundary_comps[0])
143            {
144                strictpath_comps.remove(0);
145                boundary_comps.remove(0);
146            }
147
148            let mut vb = std::path::PathBuf::new();
149            for c in strictpath_comps {
150                if let Component::Normal(name) = c {
151                    vb.push(name);
152                }
153            }
154            vb
155        }
156
157        let virtual_path = compute_virtual(strict_path.path(), strict_path.boundary());
158
159        Self {
160            inner: strict_path,
161            virtual_path,
162        }
163    }
164
165    /// Convert this `VirtualPath` back into a system‑facing `StrictPath`.
166    #[must_use = "unvirtual() consumes self — use the returned StrictPath for system-facing I/O, or prefer .as_unvirtual() to borrow without consuming"]
167    #[inline]
168    pub fn unvirtual(self) -> StrictPath<Marker> {
169        self.inner
170    }
171
172    /// Change the compile-time marker while keeping the virtual and strict views in sync.
173    ///
174    /// WHEN TO USE:
175    /// - After authenticating/authorizing a user and granting them access to a virtual path
176    /// - When escalating or downgrading permissions (e.g., ReadOnly → ReadWrite)
177    /// - When reinterpreting a path's domain (e.g., TempStorage → UserUploads)
178    ///
179    /// WHEN NOT TO USE:
180    /// - When converting between path types - conversions preserve markers automatically
181    /// - When the current marker already matches your needs - no transformation needed
182    /// - When you haven't verified authorization - NEVER change markers without checking permissions
183    ///
184    /// SECURITY:
185    /// This method performs no permission checks. Only elevate markers after verifying real
186    /// authorization out-of-band.
187    ///
188    /// # Examples
189    ///
190    /// ```rust
191    /// # use strict_path::VirtualPath;
192    /// # struct GuestAccess;
193    /// # struct UserAccess;
194    /// # let root_dir = std::env::temp_dir().join("virtual-change-marker-example");
195    /// # std::fs::create_dir_all(&root_dir)?;
196    /// # let guest_root: VirtualPath<GuestAccess> = VirtualPath::with_root(&root_dir)?;
197    /// // Simulated authorization: verify user credentials before granting access
198    /// fn grant_user_access(user_token: &str, path: VirtualPath<GuestAccess>) -> Option<VirtualPath<UserAccess>> {
199    ///     if user_token == "valid-token-12345" {
200    ///         Some(path.change_marker())  // ✅ Only after token validation
201    ///     } else {
202    ///         None  // ❌ Invalid token
203    ///     }
204    /// }
205    ///
206    /// // Untrusted input from request/CLI/config/etc.
207    /// let requested_file = "docs/readme.md";
208    /// let guest_path: VirtualPath<GuestAccess> = guest_root.virtual_join(requested_file)?;
209    /// let user_path = grant_user_access("valid-token-12345", guest_path).expect("authorized");
210    /// assert_eq!(user_path.virtualpath_display().to_string(), "/docs/readme.md");
211    /// # std::fs::remove_dir_all(&root_dir)?;
212    /// # Ok::<_, Box<dyn std::error::Error>>(())
213    /// ```
214    ///
215    /// **Type Safety Guarantee:**
216    ///
217    /// The following code **fails to compile** because you cannot pass a path with one marker
218    /// type to a function expecting a different marker type. This compile-time check enforces
219    /// that permission changes are explicit and cannot be bypassed accidentally.
220    ///
221    /// ```compile_fail
222    /// # use strict_path::VirtualPath;
223    /// # struct GuestAccess;
224    /// # struct EditorAccess;
225    /// # let root_dir = std::env::temp_dir().join("virtual-change-marker-deny");
226    /// # std::fs::create_dir_all(&root_dir).unwrap();
227    /// # let guest_root: VirtualPath<GuestAccess> = VirtualPath::with_root(&root_dir).unwrap();
228    /// fn require_editor(_: VirtualPath<EditorAccess>) {}
229    /// let guest_file = guest_root.virtual_join("docs/manual.txt").unwrap();
230    /// // ❌ Compile error: expected `VirtualPath<EditorAccess>`, found `VirtualPath<GuestAccess>`
231    /// require_editor(guest_file);
232    /// ```
233    #[must_use = "change_marker() consumes self — the original VirtualPath is moved; use the returned VirtualPath<NewMarker>"]
234    #[inline]
235    pub fn change_marker<NewMarker>(self) -> VirtualPath<NewMarker> {
236        let VirtualPath {
237            inner,
238            virtual_path,
239        } = self;
240
241        VirtualPath {
242            inner: inner.change_marker(),
243            virtual_path,
244        }
245    }
246
247    /// Consume and return the `VirtualRoot` for its boundary (no directory creation).
248    ///
249    /// # Errors
250    ///
251    /// - `StrictPathError::InvalidRestriction`: Propagated from `try_into_boundary` when the
252    ///   strict path does not exist or is not a directory.
253    #[must_use = "try_into_root() consumes self — use the returned VirtualRoot to restrict future path operations"]
254    #[inline]
255    pub fn try_into_root(self) -> Result<crate::validator::virtual_root::VirtualRoot<Marker>> {
256        Ok(self.inner.try_into_boundary()?.virtualize())
257    }
258
259    /// Consume and return a `VirtualRoot`, creating the underlying directory if missing.
260    ///
261    /// # Errors
262    ///
263    /// - `StrictPathError::InvalidRestriction`: Propagated from `try_into_boundary` or directory
264    ///   creation failures wrapped in `InvalidRestriction`.
265    #[must_use = "try_into_root_create() consumes self — use the returned VirtualRoot to restrict future path operations"]
266    #[inline]
267    pub fn try_into_root_create(
268        self,
269    ) -> Result<crate::validator::virtual_root::VirtualRoot<Marker>> {
270        let strict_path = self.inner;
271        let validated_dir = strict_path.try_into_boundary_create()?;
272        Ok(validated_dir.virtualize())
273    }
274
275    /// Borrow the underlying system‑facing `StrictPath` (no allocation).
276    #[must_use = "as_unvirtual() borrows the system-facing StrictPath — use it for system I/O or pass to functions accepting &StrictPath<Marker>"]
277    #[inline]
278    pub fn as_unvirtual(&self) -> &StrictPath<Marker> {
279        &self.inner
280    }
281
282    /// Return the underlying system path as `&OsStr` for unavoidable third-party `AsRef<Path>` interop.
283    #[must_use = "pass interop_path() directly to third-party APIs requiring AsRef<Path> — never wrap it in Path::new() or PathBuf::from(); NEVER expose this in user-facing output (use .virtualpath_display() instead)"]
284    #[inline]
285    pub fn interop_path(&self) -> &std::ffi::OsStr {
286        self.inner.interop_path()
287    }
288
289    /// Join a virtual path segment (virtual semantics) and re‑validate within the same restriction.
290    ///
291    /// Applies virtual path clamping: absolute paths are interpreted relative to the virtual root,
292    /// and traversal attempts are clamped to prevent escaping the boundary. This method maintains
293    /// the security guarantee that all `VirtualPath` instances stay within their virtual root.
294    ///
295    /// # Examples
296    ///
297    /// ```rust
298    /// # use strict_path::VirtualRoot;
299    /// # let td = tempfile::tempdir().unwrap();
300    /// let vroot: VirtualRoot = VirtualRoot::try_new_create(td.path())?;
301    /// let base = vroot.virtual_join("data")?;
302    ///
303    /// // Absolute paths are clamped to virtual root
304    /// let abs = base.virtual_join("/etc/config")?;
305    /// assert_eq!(abs.virtualpath_display().to_string(), "/etc/config");
306    /// # Ok::<(), Box<dyn std::error::Error>>(())
307    /// ```
308    #[must_use = "virtual_join() validates untrusted input against the virtual root — always handle the Result to detect escape attempts"]
309    #[inline]
310    pub fn virtual_join<P: AsRef<Path>>(&self, path: P) -> Result<Self> {
311        // Compose candidate in virtual space (do not pre-normalize lexically to preserve symlink semantics)
312        let candidate = self.virtual_path.join(path.as_ref());
313        let anchored = crate::validator::path_history::PathHistory::new(candidate)
314            .canonicalize_anchored(self.inner.boundary())?;
315        let boundary_path = clamp(self.inner.boundary(), anchored)?;
316        Ok(VirtualPath::new(boundary_path))
317    }
318
319    // No local clamping helpers; virtual flows should route through
320    // PathHistory::virtualize_to_jail + PathBoundary::strict_join to avoid drift.
321
322    /// Return the parent virtual path, or `None` at the virtual root.
323    #[must_use = "returns a Result<Option> — handle both the error case and the None (at virtual root) case"]
324    pub fn virtualpath_parent(&self) -> Result<Option<Self>> {
325        match self.virtual_path.parent() {
326            Some(parent_virtual_path) => {
327                let anchored = crate::validator::path_history::PathHistory::new(
328                    parent_virtual_path.to_path_buf(),
329                )
330                .canonicalize_anchored(self.inner.boundary())?;
331                let validated_path = clamp(self.inner.boundary(), anchored)?;
332                Ok(Some(VirtualPath::new(validated_path)))
333            }
334            None => Ok(None),
335        }
336    }
337
338    /// Return a new virtual path with file name changed, preserving clamping.
339    #[must_use = "returns a new validated VirtualPath with the file name replaced — the original is unchanged; handle the Result to detect boundary escapes"]
340    #[inline]
341    pub fn virtualpath_with_file_name<S: AsRef<OsStr>>(&self, file_name: S) -> Result<Self> {
342        let candidate = self.virtual_path.with_file_name(file_name);
343        let anchored = crate::validator::path_history::PathHistory::new(candidate)
344            .canonicalize_anchored(self.inner.boundary())?;
345        let validated_path = clamp(self.inner.boundary(), anchored)?;
346        Ok(VirtualPath::new(validated_path))
347    }
348
349    /// Return a new virtual path with the extension changed, preserving clamping.
350    #[must_use = "returns a new validated VirtualPath with the extension changed — the original is unchanged; handle the Result to detect boundary escapes"]
351    pub fn virtualpath_with_extension<S: AsRef<OsStr>>(&self, extension: S) -> Result<Self> {
352        if self.virtual_path.file_name().is_none() {
353            return Err(StrictPathError::path_escapes_boundary(
354                self.virtual_path.clone(),
355                self.inner.boundary().path().to_path_buf(),
356            ));
357        }
358
359        // WHY: `Path::with_extension` panics when the extension contains a
360        // path separator. Untrusted callers must get an `Err`, never a crash.
361        let candidate =
362            crate::path::with_validated_extension(&self.virtual_path, extension.as_ref())?;
363        let anchored = crate::validator::path_history::PathHistory::new(candidate)
364            .canonicalize_anchored(self.inner.boundary())?;
365        let validated_path = clamp(self.inner.boundary(), anchored)?;
366        Ok(VirtualPath::new(validated_path))
367    }
368
369    /// Return the file name component of the virtual path, if any.
370    #[must_use]
371    #[inline]
372    pub fn virtualpath_file_name(&self) -> Option<&OsStr> {
373        self.virtual_path.file_name()
374    }
375
376    /// Return the file stem of the virtual path, if any.
377    #[must_use]
378    #[inline]
379    pub fn virtualpath_file_stem(&self) -> Option<&OsStr> {
380        self.virtual_path.file_stem()
381    }
382
383    /// Return the extension of the virtual path, if any.
384    #[must_use]
385    #[inline]
386    pub fn virtualpath_extension(&self) -> Option<&OsStr> {
387        self.virtual_path.extension()
388    }
389
390    /// Return `true` if the virtual path starts with the given prefix (virtual semantics).
391    #[must_use]
392    #[inline]
393    pub fn virtualpath_starts_with<P: AsRef<Path>>(&self, p: P) -> bool {
394        self.virtual_path.starts_with(p)
395    }
396
397    /// Return `true` if the virtual path ends with the given suffix (virtual semantics).
398    #[must_use]
399    #[inline]
400    pub fn virtualpath_ends_with<P: AsRef<Path>>(&self, p: P) -> bool {
401        self.virtual_path.ends_with(p)
402    }
403
404    /// Return a Display wrapper that shows a rooted virtual path (e.g., `"/a/b.txt").
405    #[must_use = "virtualpath_display() returns a safe user-facing path representation — use this (not interop_path()) in API responses, logs, and UI"]
406    #[inline]
407    pub fn virtualpath_display(&self) -> VirtualPathDisplay<'_, Marker> {
408        VirtualPathDisplay(self)
409    }
410
411    /// Return `true` if the underlying system path exists.
412    #[must_use]
413    #[inline]
414    pub fn exists(&self) -> bool {
415        self.inner.exists()
416    }
417
418    /// Return `true` if the underlying system path is a file.
419    #[must_use]
420    #[inline]
421    pub fn is_file(&self) -> bool {
422        self.inner.is_file()
423    }
424
425    /// Return `true` if the underlying system path is a directory.
426    #[must_use]
427    #[inline]
428    pub fn is_dir(&self) -> bool {
429        self.inner.is_dir()
430    }
431
432    /// Return metadata for the underlying system path.
433    #[inline]
434    pub fn metadata(&self) -> std::io::Result<std::fs::Metadata> {
435        self.inner.metadata()
436    }
437
438    /// Read the file contents as `String` from the underlying system path.
439    #[inline]
440    pub fn read_to_string(&self) -> std::io::Result<String> {
441        self.inner.read_to_string()
442    }
443
444    /// Read raw bytes from the underlying system path.
445    #[inline]
446    pub fn read(&self) -> std::io::Result<Vec<u8>> {
447        self.inner.read()
448    }
449
450    /// Return metadata for the underlying system path without following symlinks.
451    #[inline]
452    pub fn symlink_metadata(&self) -> std::io::Result<std::fs::Metadata> {
453        self.inner.symlink_metadata()
454    }
455
456    /// Set permissions on the file or directory at this path.
457    ///
458    #[inline]
459    pub fn set_permissions(&self, perm: std::fs::Permissions) -> std::io::Result<()> {
460        self.inner.set_permissions(perm)
461    }
462
463    /// Check if the path exists, returning an error on permission issues.
464    ///
465    /// Unlike `exists()` which returns `false` on permission errors, this method
466    /// distinguishes between "path does not exist" (`Ok(false)`) and "cannot check
467    /// due to permission error" (`Err(...)`).
468    ///
469    #[inline]
470    pub fn try_exists(&self) -> std::io::Result<bool> {
471        self.inner.try_exists()
472    }
473
474    /// Create an empty file if it doesn't exist, or update the modification time if it does.
475    ///
476    /// This is a convenience method combining file creation and mtime update.
477    /// Uses `OpenOptions` with `create(true).write(true)` which creates the file
478    /// if missing or opens it for writing if it exists, updating mtime on close.
479    ///
480    pub fn touch(&self) -> std::io::Result<()> {
481        self.inner.touch()
482    }
483
484    /// Read directory entries (discovery). Re‑join names with `virtual_join(...)` to preserve clamping.
485    pub fn read_dir(&self) -> std::io::Result<std::fs::ReadDir> {
486        self.inner.read_dir()
487    }
488
489    /// Read directory entries as validated `VirtualPath` values (auto re-joins each entry).
490    ///
491    /// Unlike `read_dir()` which returns raw `std::fs::DirEntry`, this method automatically
492    /// validates each directory entry through `virtual_join()`, returning an iterator of
493    /// `Result<VirtualPath<Marker>>`. This eliminates the need for manual re-validation loops
494    /// while preserving the virtual path semantics.
495    ///
496    /// # Errors
497    ///
498    /// - `std::io::Error`: If the directory cannot be read.
499    /// - Each yielded item may also be `Err` if validation fails for that entry.
500    ///
501    /// # Examples
502    ///
503    /// ```rust
504    /// # use strict_path::{VirtualRoot, VirtualPath};
505    /// # let temp = tempfile::tempdir()?;
506    /// # let vroot: VirtualRoot = VirtualRoot::try_new(temp.path())?;
507    /// # let dir = vroot.virtual_join("uploads")?;
508    /// # dir.create_dir_all()?;
509    /// # vroot.virtual_join("uploads/file1.txt")?.write("a")?;
510    /// # vroot.virtual_join("uploads/file2.txt")?.write("b")?;
511    /// // Iterate with automatic validation
512    /// for entry in dir.virtual_read_dir()? {
513    ///     let child: VirtualPath = entry?;
514    ///     let child_display = child.virtualpath_display();
515    ///     println!("{child_display}");
516    /// }
517    /// # Ok::<_, Box<dyn std::error::Error>>(())
518    /// ```
519    pub fn virtual_read_dir(&self) -> std::io::Result<VirtualReadDir<'_, Marker>> {
520        let inner = std::fs::read_dir(self.inner.path())?;
521        Ok(VirtualReadDir {
522            inner,
523            parent: self,
524        })
525    }
526}