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}