Skip to main content

strict_path/validator/
path_history.rs

1//! Type-state pipeline that canonicalizes and boundary-checks paths.
2//!
3//! `PathHistory<S>` is a newtype over `PathBuf` whose phantom type parameter `S`
4//! records which validation stages have been applied. The compiler rejects use of a
5//! partially-validated path where a fully-validated one is required, making it
6//! impossible to skip a stage accidentally. The states flow:
7//! `Raw → Canonicalized → BoundaryChecked` (strict) or
8//! `Raw → (Virtualized) → Canonicalized → BoundaryChecked` (virtual).
9//! This module is internal; callers interact through `PathBoundary` and `VirtualRoot`.
10use crate::{Result, StrictPathError};
11use soft_canonicalize::{anchored_canonicalize, soft_canonicalize};
12use std::ops::Deref;
13use std::path::{Path, PathBuf};
14
15/// Unvalidated state — constructed from any `AsRef<Path>` input.
16#[derive(Debug, Clone)]
17pub struct Raw;
18/// Fully canonicalized — `.`, `..`, and symlinks resolved to real targets.
19#[derive(Debug, Clone)]
20pub struct Canonicalized;
21/// Proven to reside within the boundary — ready for `StrictPath` construction.
22#[derive(Debug, Clone)]
23pub struct BoundaryChecked;
24/// Canonicalized path confirmed to exist as a directory (used for boundary roots).
25#[derive(Debug, Clone)]
26pub struct Exists;
27/// Pre-canonicalization: components clamped in virtual space so `..` cannot
28/// walk above the virtual root.
29#[derive(Debug, Clone)]
30pub struct Virtualized;
31
32/// Type-state wrapper over `PathBuf` recording which validation stages have been applied.
33///
34/// The phantom `History` parameter is a nested tuple that grows as stages complete:
35/// `Raw → (Raw, Canonicalized) → ((Raw, Canonicalized), BoundaryChecked)`.  
36/// This prevents accidental use of a partially-validated path where a fully-validated one
37/// is required — the compiler rejects the mismatch.
38#[derive(Debug, Clone)]
39pub struct PathHistory<History> {
40    inner: std::path::PathBuf,
41    _marker: std::marker::PhantomData<History>,
42}
43
44impl<H> AsRef<Path> for PathHistory<H> {
45    #[inline]
46    fn as_ref(&self) -> &Path {
47        &self.inner
48    }
49}
50
51impl<H> Deref for PathHistory<H> {
52    type Target = Path;
53    #[inline]
54    fn deref(&self) -> &Self::Target {
55        &self.inner
56    }
57}
58
59impl PathHistory<Raw> {
60    /// Wrap a raw path for the start of the validation pipeline.
61    #[inline]
62    pub fn new<P: Into<std::path::PathBuf>>(path: P) -> Self {
63        PathHistory {
64            inner: path.into(),
65            _marker: std::marker::PhantomData,
66        }
67    }
68}
69
70impl<H> PathHistory<H> {
71    /// Consume the wrapper and return the underlying `PathBuf`.
72    #[inline]
73    pub fn into_inner(self) -> std::path::PathBuf {
74        self.inner
75    }
76
77    /// Virtualizes this path by preparing a path boundary-anchored system path for validation.
78    ///
79    /// Semantics:
80    /// - Clamps traversal (.., .) in virtual space so results never walk above the virtual root.
81    /// - Absolute inputs are treated as requests relative to the virtual root (drop only the root/prefix).
82    /// - Does not resolve symlinks; that is handled by canonicalization in `PathBoundary::restricted_join`.
83    /// - Returns a path under the path boundary as root to be canonicalized and boundary-checked.
84    pub fn virtualize_to_restriction<Marker>(
85        self,
86        restriction: &crate::PathBoundary<Marker>,
87    ) -> PathHistory<(H, Virtualized)> {
88        // Build a clamped relative path by processing components and preventing
89        // traversal above the virtual root.
90        use std::path::Component;
91        let mut parts: Vec<std::ffi::OsString> = Vec::new();
92        for comp in self.inner.components() {
93            match comp {
94                Component::Normal(name) => parts.push(name.to_os_string()),
95                Component::CurDir => {}
96                Component::ParentDir => {
97                    if parts.pop().is_none() {
98                        // At virtual root; ignore extra ".."
99                    }
100                }
101                Component::RootDir | Component::Prefix(_) => {
102                    // Treat as virtual root reset; clear accumulated parts
103                    parts.clear();
104                }
105            }
106        }
107        let mut rel = PathBuf::new();
108        for p in parts {
109            rel.push(p);
110        }
111
112        PathHistory {
113            inner: restriction.path().join(rel),
114            _marker: std::marker::PhantomData,
115        }
116    }
117
118    /// Canonicalize with `soft-canonicalize`, resolving symlinks, `.`, and `..`.
119    ///
120    /// WHY: Canonicalization is the foundation of the security model — it guarantees
121    /// that the boundary check operates on the real, resolved path and cannot be
122    /// tricked by symlinks, short names, or relative components.
123    pub fn canonicalize(self) -> Result<PathHistory<(H, Canonicalized)>> {
124        let canon = soft_canonicalize(&self.inner)
125            .map_err(|e| StrictPathError::path_resolution_error(self.inner.clone(), e))?;
126        Ok(PathHistory {
127            inner: canon,
128            _marker: std::marker::PhantomData,
129        })
130    }
131
132    /// Canonicalize relative to a path boundary root using anchored semantics (virtual clamp + resolution).
133    /// Returns a Canonicalized state; boundary checking is still required.
134    pub fn canonicalize_anchored<Marker>(
135        self,
136        anchor: &crate::PathBoundary<Marker>,
137    ) -> Result<PathHistory<(H, Canonicalized)>> {
138        let canon = anchored_canonicalize(anchor.path(), &self.inner)
139            .map_err(|e| StrictPathError::path_resolution_error(self.inner.clone(), e))?;
140        Ok(PathHistory {
141            inner: canon,
142            _marker: std::marker::PhantomData,
143        })
144    }
145
146    /// Verify the path exists on disk and transition to the `Exists` state.
147    ///
148    /// Used by `PathBoundary::try_new` to confirm the boundary directory is real
149    /// before storing it as the trusted root.
150    pub fn verify_exists(self) -> Option<PathHistory<(H, Exists)>> {
151        self.inner.exists().then_some(PathHistory {
152            inner: self.inner,
153            _marker: std::marker::PhantomData,
154        })
155    }
156}
157
158impl<H> PathHistory<(H, Canonicalized)> {
159    /// Prove this canonicalized path starts with the boundary root.
160    ///
161    /// WHY: This is the single gate that enforces the "no escape" invariant.
162    /// Only paths that pass this check become `StrictPath` values, so every
163    /// downstream consumer is guaranteed to hold a path within the boundary.
164    #[inline]
165    pub fn boundary_check(
166        self,
167        restriction: &PathHistory<((Raw, Canonicalized), Exists)>,
168    ) -> Result<PathHistory<((H, Canonicalized), BoundaryChecked)>> {
169        if !self.starts_with(restriction) {
170            return Err(StrictPathError::path_escapes_boundary(
171                self.into_inner(),
172                restriction.to_path_buf(),
173            ));
174        }
175        Ok(PathHistory {
176            inner: self.inner,
177            _marker: std::marker::PhantomData,
178        })
179    }
180}
181
182// No separate anchored type-state after canonicalization; use Canonicalized