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}