Skip to main content

strict_path/validator/
path_boundary.rs

1//! `PathBoundary<Marker>` — the security perimeter for validated path operations.
2//!
3//! A `PathBoundary` represents a trusted filesystem directory. All `StrictPath` values
4//! produced through it are guaranteed, at construction time, to resolve inside that
5//! directory. This guarantee is provided by `canonicalize_and_enforce_restriction_boundary`,
6//! which canonicalizes the candidate path (resolving symlinks and `..`) and then verifies
7//! it starts with the canonicalized boundary. Any path that would escape is rejected with
8//! `PathEscapesBoundary` before any I/O occurs.
9use crate::error::StrictPathError;
10use crate::path::strict_path::StrictPath;
11use crate::validator::path_history::*;
12use crate::Result;
13
14use std::io::{Error as IoError, ErrorKind};
15use std::marker::PhantomData;
16use std::path::Path;
17use std::sync::Arc;
18
19/// Canonicalize a candidate path and enforce the `PathBoundary` boundary, returning a `StrictPath`.
20///
21/// # Errors
22///
23/// - `StrictPathError::PathResolutionError`: Canonicalization fails (I/O or resolution error).
24/// - `StrictPathError::PathEscapesBoundary`: Resolved path would escape the boundary.
25///
26/// # Examples
27///
28/// ```rust
29/// # use strict_path::{PathBoundary, Result};
30/// # fn main() -> Result<()> {
31/// let sandbox = PathBoundary::<()>::try_new_create("./sandbox")?;
32/// // Untrusted input from request/CLI/config/etc.
33/// let user_input = "sub/file.txt";
34/// // Use the public API that exercises the same validation pipeline
35/// // as this internal helper.
36/// let file = sandbox.strict_join(user_input)?;
37/// assert!(file.strictpath_display().to_string().contains("sandbox"));
38/// # Ok(())
39/// # }
40/// ```
41pub(crate) fn canonicalize_and_enforce_restriction_boundary<Marker>(
42    path: impl AsRef<Path>,
43    restriction: &PathBoundary<Marker>,
44) -> Result<StrictPath<Marker>> {
45    // Relative paths are anchored to the boundary so they cannot be
46    // interpreted relative to the process CWD (which is outside our control).
47    // Absolute paths are accepted as-is because canonicalization + boundary_check
48    // will still reject any path that resolves outside the boundary.
49    let target_path = if path.as_ref().is_absolute() {
50        path.as_ref().to_path_buf()
51    } else {
52        restriction.path().join(path.as_ref())
53    };
54
55    let canonicalized = PathHistory::<Raw>::new(target_path).canonicalize()?;
56
57    let validated_path = canonicalized.boundary_check(&restriction.path)?;
58
59    Ok(StrictPath::new(
60        Arc::new(restriction.clone()),
61        validated_path,
62    ))
63}
64
65/// A path boundary that serves as the secure foundation for validated path operations.
66///
67/// Represent the trusted filesystem boundary directory for all strict and virtual path
68/// operations. All `StrictPath`/`VirtualPath` values derived from a `PathBoundary` are
69/// guaranteed to remain within this boundary.
70///
71/// # Examples
72///
73/// ```rust
74/// # use strict_path::PathBoundary;
75/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
76/// let data_dir = PathBoundary::<()>::try_new_create("./data")?;
77/// // Untrusted input from request/CLI/config/etc.
78/// let requested_file = "logs/app.log";
79/// let file = data_dir.strict_join(requested_file)?;
80/// let file_display = file.strictpath_display();
81/// println!("{file_display}");
82/// # Ok(())
83/// # }
84/// ```
85#[must_use = "a PathBoundary is validated and ready to enforce path restrictions — call .strict_join() to validate untrusted input, .into_strictpath() to get the boundary path, or pass to functions that accept &PathBoundary<Marker>"]
86#[doc(alias = "jail")]
87#[doc(alias = "chroot")]
88#[doc(alias = "sandbox")]
89#[doc(alias = "sanitize")]
90#[doc(alias = "boundary")]
91pub struct PathBoundary<Marker = ()> {
92    path: Arc<PathHistory<((Raw, Canonicalized), Exists)>>,
93    _marker: PhantomData<Marker>,
94}
95
96impl<Marker> Clone for PathBoundary<Marker> {
97    fn clone(&self) -> Self {
98        Self {
99            path: self.path.clone(),
100            _marker: PhantomData,
101        }
102    }
103}
104
105impl<Marker> Eq for PathBoundary<Marker> {}
106
107impl<M1, M2> PartialEq<PathBoundary<M2>> for PathBoundary<M1> {
108    #[inline]
109    fn eq(&self, other: &PathBoundary<M2>) -> bool {
110        self.path() == other.path()
111    }
112}
113
114impl<Marker> std::hash::Hash for PathBoundary<Marker> {
115    #[inline]
116    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
117        self.path().hash(state);
118    }
119}
120
121impl<Marker> PartialOrd for PathBoundary<Marker> {
122    #[inline]
123    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
124        Some(self.cmp(other))
125    }
126}
127
128impl<Marker> Ord for PathBoundary<Marker> {
129    #[inline]
130    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
131        self.path().cmp(other.path())
132    }
133}
134
135#[cfg(feature = "virtual-path")]
136impl<M1, M2> PartialEq<crate::validator::virtual_root::VirtualRoot<M2>> for PathBoundary<M1> {
137    #[inline]
138    fn eq(&self, other: &crate::validator::virtual_root::VirtualRoot<M2>) -> bool {
139        self.path() == other.path()
140    }
141}
142
143impl<Marker> PartialEq<Path> for PathBoundary<Marker> {
144    #[inline]
145    fn eq(&self, other: &Path) -> bool {
146        self.path() == other
147    }
148}
149
150impl<Marker> PartialEq<std::path::PathBuf> for PathBoundary<Marker> {
151    #[inline]
152    fn eq(&self, other: &std::path::PathBuf) -> bool {
153        self.eq(other.as_path())
154    }
155}
156
157impl<Marker> PartialEq<&std::path::Path> for PathBoundary<Marker> {
158    #[inline]
159    fn eq(&self, other: &&std::path::Path) -> bool {
160        self.eq(*other)
161    }
162}
163
164impl<Marker> PathBoundary<Marker> {
165    /// Creates a new `PathBoundary` anchored at `restriction_path` (which must already exist and be a directory).
166    ///
167    /// Create a boundary anchored at an existing directory (must exist and be a directory).
168    ///
169    /// # Errors
170    ///
171    /// - `StrictPathError::InvalidRestriction`: Boundary directory is missing, not a directory, or cannot be canonicalized.
172    ///
173    /// # Examples
174    ///
175    /// ```rust
176    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
177    /// use strict_path::PathBoundary;
178    /// let data_dir = PathBoundary::<()>::try_new("./data")?;
179    /// # Ok(())
180    /// # }
181    /// ```
182    #[must_use = "this returns a Result containing the validated PathBoundary — handle the Result to detect invalid boundary directories"]
183    #[inline]
184    pub fn try_new<P: AsRef<Path>>(restriction_path: P) -> Result<Self> {
185        let restriction_path = restriction_path.as_ref();
186        let raw = PathHistory::<Raw>::new(restriction_path);
187
188        let canonicalized = raw.canonicalize()?;
189
190        let verified_exists = match canonicalized.verify_exists() {
191            Some(path) => path,
192            None => {
193                let io = IoError::new(
194                    ErrorKind::NotFound,
195                    "The specified PathBoundary path does not exist.",
196                );
197                return Err(StrictPathError::invalid_restriction(
198                    restriction_path.to_path_buf(),
199                    io,
200                ));
201            }
202        };
203
204        if !verified_exists.is_dir() {
205            let error = IoError::new(
206                ErrorKind::InvalidInput,
207                "The specified PathBoundary path exists but is not a directory.",
208            );
209            return Err(StrictPathError::invalid_restriction(
210                restriction_path.to_path_buf(),
211                error,
212            ));
213        }
214
215        Ok(Self {
216            path: Arc::new(verified_exists),
217            _marker: PhantomData,
218        })
219    }
220
221    /// Creates the directory if missing, then constructs a new `PathBoundary`.
222    ///
223    /// Ensure the boundary directory exists (create if missing) and construct a new boundary.
224    ///
225    /// # Errors
226    ///
227    /// - `StrictPathError::InvalidRestriction`: Directory creation/canonicalization fails.
228    ///
229    /// # Examples
230    ///
231    /// ```rust
232    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
233    /// use strict_path::PathBoundary;
234    /// let data_dir = PathBoundary::<()>::try_new_create("./data")?;
235    /// # Ok(())
236    /// # }
237    /// ```
238    #[must_use = "this returns a Result containing the validated PathBoundary — handle the Result to detect invalid boundary directories"]
239    pub fn try_new_create<P: AsRef<Path>>(boundary_dir: P) -> Result<Self> {
240        let boundary_path = boundary_dir.as_ref();
241        if !boundary_path.exists() {
242            std::fs::create_dir_all(boundary_path).map_err(|e| {
243                StrictPathError::invalid_restriction(boundary_path.to_path_buf(), e)
244            })?;
245        }
246        Self::try_new(boundary_path)
247    }
248
249    /// Join a candidate path to the boundary and return a validated `StrictPath`.
250    ///
251    /// # Errors
252    ///
253    /// - `StrictPathError::PathResolutionError`, `StrictPathError::PathEscapesBoundary`.
254    #[must_use = "strict_join() validates untrusted input against the boundary — always handle the Result to detect path traversal attacks"]
255    #[inline]
256    pub fn strict_join(&self, candidate_path: impl AsRef<Path>) -> Result<StrictPath<Marker>> {
257        canonicalize_and_enforce_restriction_boundary(candidate_path, self)
258    }
259
260    /// Consume this boundary and substitute a new marker type.
261    ///
262    /// Mirrors [`crate::StrictPath::change_marker`] and [`crate::VirtualPath::change_marker`], enabling
263    /// marker transformation after authorization checks. Use this when encoding proven
264    /// authorization into the type system (e.g., after validating a user's permissions).
265    /// The consumption makes marker changes explicit during code review.
266    ///
267    /// # Examples
268    ///
269    /// ```rust
270    /// # use strict_path::PathBoundary;
271    /// struct ReadOnly;
272    /// struct ReadWrite;
273    ///
274    /// let read_only_dir: PathBoundary<ReadOnly> = PathBoundary::try_new_create("./data")?;
275    ///
276    /// // After authorization check...
277    /// let write_access_dir: PathBoundary<ReadWrite> = read_only_dir.change_marker();
278    /// # Ok::<_, Box<dyn std::error::Error>>(())
279    /// ```
280    #[must_use = "change_marker() consumes self — the original PathBoundary is moved; use the returned PathBoundary<NewMarker>"]
281    #[inline]
282    pub fn change_marker<NewMarker>(self) -> PathBoundary<NewMarker> {
283        PathBoundary {
284            path: self.path,
285            _marker: PhantomData,
286        }
287    }
288
289    /// Consume this boundary and return a `StrictPath` anchored at the boundary directory.
290    ///
291    /// # Errors
292    ///
293    /// - `StrictPathError::PathResolutionError`: Canonicalization fails (directory removed or inaccessible).
294    /// - `StrictPathError::PathEscapesBoundary`: Guard against race conditions that move the directory.
295    ///
296    /// # Examples
297    ///
298    /// ```rust
299    /// # use strict_path::{PathBoundary, StrictPath};
300    /// let data_dir: PathBoundary = PathBoundary::try_new_create("./data")?;
301    /// let data_path: StrictPath = data_dir.into_strictpath()?;
302    /// assert!(data_path.is_dir());
303    /// # Ok::<_, Box<dyn std::error::Error>>(())
304    /// ```
305    #[must_use = "into_strictpath() consumes the PathBoundary — use the returned StrictPath for I/O operations"]
306    #[inline]
307    pub fn into_strictpath(self) -> Result<StrictPath<Marker>> {
308        let root_history = self.path.clone();
309        let validated = PathHistory::<Raw>::new(root_history.as_ref().to_path_buf())
310            .canonicalize()?
311            .boundary_check(root_history.as_ref())?;
312        Ok(StrictPath::new(Arc::new(self), validated))
313    }
314
315    /// Returns the canonicalized PathBoundary directory path. Kept crate-private to avoid leaking raw path.
316    #[inline]
317    pub(crate) fn path(&self) -> &Path {
318        self.path.as_ref()
319    }
320
321    /// Internal: returns the canonicalized PathHistory of the PathBoundary directory for boundary checks.
322    #[cfg(feature = "virtual-path")]
323    #[inline]
324    pub(crate) fn stated_path(&self) -> &PathHistory<((Raw, Canonicalized), Exists)> {
325        &self.path
326    }
327
328    /// Returns true if the PathBoundary directory exists.
329    ///
330    /// This is always true for a constructed PathBoundary, but we query the filesystem for robustness.
331    #[must_use]
332    #[inline]
333    pub fn exists(&self) -> bool {
334        self.path.exists()
335    }
336
337    /// Return the boundary directory path as `&OsStr` for unavoidable third-party `AsRef<Path>` interop (no allocation).
338    #[must_use = "pass interop_path() directly to third-party APIs requiring AsRef<Path> — never wrap it in Path::new() or PathBuf::from() as that defeats boundary safety"]
339    #[inline]
340    pub fn interop_path(&self) -> &std::ffi::OsStr {
341        self.path.as_os_str()
342    }
343
344    /// Returns a Display wrapper that shows the PathBoundary directory system path.
345    #[must_use = "strictpath_display() shows the real system path (admin/debug use) — for user-facing output prefer VirtualPath::virtualpath_display() which hides internal paths"]
346    #[inline]
347    pub fn strictpath_display(&self) -> std::path::Display<'_> {
348        self.path().display()
349    }
350
351    /// Return filesystem metadata for the boundary directory.
352    #[inline]
353    pub fn metadata(&self) -> std::io::Result<std::fs::Metadata> {
354        std::fs::metadata(self.path())
355    }
356
357    /// Create a symbolic link at `link_path` pointing to this boundary's directory.
358    ///
359    pub fn strict_symlink<P: AsRef<Path>>(&self, link_path: P) -> std::io::Result<()> {
360        let root = self
361            .clone()
362            .into_strictpath()
363            .map_err(std::io::Error::other)?;
364
365        root.strict_symlink(link_path)
366    }
367
368    /// Create a hard link at `link_path` pointing to this boundary's directory.
369    ///
370    /// Accepts the same `link_path: impl AsRef<Path>` parameter as `strict_symlink` and returns `io::Result<()>`.
371    pub fn strict_hard_link<P: AsRef<Path>>(&self, link_path: P) -> std::io::Result<()> {
372        let root = self
373            .clone()
374            .into_strictpath()
375            .map_err(std::io::Error::other)?;
376
377        root.strict_hard_link(link_path)
378    }
379
380    /// Create a Windows NTFS directory junction at `link_path` pointing to this boundary's directory.
381    ///
382    /// - Windows-only and behind the `junctions` crate feature.
383    /// - Junctions are directory-only.
384    #[cfg(all(windows, feature = "junctions"))]
385    pub fn strict_junction<P: AsRef<Path>>(&self, link_path: P) -> std::io::Result<()> {
386        let root = self
387            .clone()
388            .into_strictpath()
389            .map_err(std::io::Error::other)?;
390
391        root.strict_junction(link_path)
392    }
393
394    /// Read directory entries under the boundary directory (discovery only).
395    #[inline]
396    pub fn read_dir(&self) -> std::io::Result<std::fs::ReadDir> {
397        std::fs::read_dir(self.path())
398    }
399
400    /// Iterate directory entries under the boundary, yielding validated `StrictPath` values.
401    ///
402    /// Unlike `read_dir()` which returns raw `std::fs::DirEntry` values requiring manual
403    /// re-validation, this method yields `StrictPath` entries directly. Each entry is
404    /// automatically validated through `strict_join()` so you can use it immediately
405    /// for I/O operations without additional validation.
406    ///
407    /// # Examples
408    ///
409    /// ```rust
410    /// use strict_path::PathBoundary;
411    ///
412    /// # let temp = tempfile::tempdir()?;
413    /// let data_dir: PathBoundary = PathBoundary::try_new(temp.path())?;
414    /// # data_dir.strict_join("file.txt")?.write("test")?;
415    ///
416    /// // Auto-validated iteration - no manual re-join needed!
417    /// for entry in data_dir.strict_read_dir()? {
418    ///     let child = entry?;
419    ///     println!("Found: {}", child.strictpath_display());
420    /// }
421    /// # Ok::<_, Box<dyn std::error::Error>>(())
422    /// ```
423    #[inline]
424    pub fn strict_read_dir(&self) -> std::io::Result<BoundaryReadDir<'_, Marker>> {
425        Ok(BoundaryReadDir {
426            inner: std::fs::read_dir(self.path())?,
427            boundary: self,
428        })
429    }
430
431    /// Remove the boundary directory (non-recursive); fails if not empty.
432    #[inline]
433    pub fn remove_dir(&self) -> std::io::Result<()> {
434        std::fs::remove_dir(self.path())
435    }
436
437    /// Recursively remove the boundary directory and its contents.
438    #[inline]
439    pub fn remove_dir_all(&self) -> std::io::Result<()> {
440        std::fs::remove_dir_all(self.path())
441    }
442
443    /// Convert this boundary into a `VirtualRoot` for virtual path operations.
444    #[must_use = "virtualize() consumes self — use the returned VirtualRoot for virtual path operations (.virtual_join(), .into_virtualpath())"]
445    #[cfg(feature = "virtual-path")]
446    #[inline]
447    pub fn virtualize(self) -> crate::VirtualRoot<Marker> {
448        crate::VirtualRoot {
449            root: self,
450            _marker: PhantomData,
451        }
452    }
453
454    // Note: Do not add new crate-private helpers unless necessary; use existing flows.
455}
456
457impl<Marker> std::fmt::Debug for PathBoundary<Marker> {
458    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
459        f.debug_struct("PathBoundary")
460            .field("path", &self.path.as_ref())
461            .field("marker", &std::any::type_name::<Marker>())
462            .finish()
463    }
464}
465
466impl<Marker: Default> std::str::FromStr for PathBoundary<Marker> {
467    type Err = crate::StrictPathError;
468
469    /// Parse a `PathBoundary` from a string path, validating that it already
470    /// exists as a directory.
471    ///
472    /// WHY VALIDATE-ONLY: When `PathBoundary` is parsed from untrusted input
473    /// (serde deserialization of a config file, a CLI flag, an environment
474    /// variable), the string controls which directory on disk is created. A
475    /// `FromStr` that eagerly calls `create_dir_all` would let an attacker who
476    /// controls that string touch any directory the process has write access
477    /// to. `from_str` intentionally does not create anything; use
478    /// [`PathBoundary::try_new_create`] explicitly when directory creation is
479    /// the desired side effect.
480    ///
481    /// ```rust
482    /// # use strict_path::PathBoundary;
483    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
484    /// # let tmp = tempfile::tempdir()?;
485    /// # let p = tmp.path().to_string_lossy().to_string();
486    /// let data_dir: PathBoundary<()> = p.parse()?;
487    /// assert!(data_dir.exists());
488    /// # Ok(())
489    /// # }
490    /// ```
491    #[inline]
492    fn from_str(path: &str) -> std::result::Result<Self, Self::Err> {
493        Self::try_new(path)
494    }
495}
496
497// ============================================================
498// BoundaryReadDir — Iterator for validated directory entries
499// ============================================================
500
501/// Iterator over directory entries that yields validated `StrictPath` values.
502///
503/// Created by `PathBoundary::strict_read_dir()`. Each iteration automatically validates
504/// the directory entry through `strict_join()`, so you get `StrictPath` values directly
505/// instead of raw `std::fs::DirEntry` that would require manual re-validation.
506///
507/// # Examples
508///
509/// ```rust
510/// # use strict_path::PathBoundary;
511/// # let temp = tempfile::tempdir()?;
512/// let data_dir: PathBoundary = PathBoundary::try_new(temp.path())?;
513/// # data_dir.strict_join("readme.md")?.write("# Docs")?;
514/// for entry in data_dir.strict_read_dir()? {
515///     let child = entry?;
516///     if child.is_file() {
517///         println!("File: {}", child.strictpath_display());
518///     }
519/// }
520/// # Ok::<_, Box<dyn std::error::Error>>(())
521/// ```
522pub struct BoundaryReadDir<'a, Marker> {
523    inner: std::fs::ReadDir,
524    boundary: &'a PathBoundary<Marker>,
525}
526
527impl<Marker> std::fmt::Debug for BoundaryReadDir<'_, Marker> {
528    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
529        f.debug_struct("BoundaryReadDir")
530            .field("boundary", &self.boundary.strictpath_display())
531            .finish_non_exhaustive()
532    }
533}
534
535impl<Marker: Clone> Iterator for BoundaryReadDir<'_, Marker> {
536    type Item = std::io::Result<crate::StrictPath<Marker>>;
537
538    fn next(&mut self) -> Option<Self::Item> {
539        match self.inner.next()? {
540            Ok(entry) => {
541                let file_name = entry.file_name();
542                match self.boundary.strict_join(file_name) {
543                    Ok(strict_path) => Some(Ok(strict_path)),
544                    Err(e) => Some(Err(std::io::Error::new(std::io::ErrorKind::InvalidData, e))),
545                }
546            }
547            Err(e) => Some(Err(e)),
548        }
549    }
550}
551//