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