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