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 `VirtualPath::change_marker`
263 /// when using `VirtualPath`), enabling marker transformation after authorization
264 /// checks. Use this when encoding proven authorization into the type system
265 /// (e.g., after validating a user's permissions). The consumption makes marker
266 /// changes explicit during code review.
267 ///
268 /// # Examples
269 ///
270 /// ```rust
271 /// # use strict_path::PathBoundary;
272 /// struct ReadOnly;
273 /// struct ReadWrite;
274 ///
275 /// let read_only_dir: PathBoundary<ReadOnly> = PathBoundary::try_new_create("./data")?;
276 ///
277 /// // After authorization check...
278 /// let write_access_dir: PathBoundary<ReadWrite> = read_only_dir.change_marker();
279 /// # Ok::<_, Box<dyn std::error::Error>>(())
280 /// ```
281 #[must_use = "change_marker() consumes self — the original PathBoundary is moved; use the returned PathBoundary<NewMarker>"]
282 #[inline]
283 pub fn change_marker<NewMarker>(self) -> PathBoundary<NewMarker> {
284 PathBoundary {
285 path: self.path,
286 _marker: PhantomData,
287 }
288 }
289
290 /// Consume this boundary and return a `StrictPath` anchored at the boundary directory.
291 ///
292 /// # Errors
293 ///
294 /// - `StrictPathError::PathResolutionError`: Canonicalization fails (directory removed or inaccessible).
295 /// - `StrictPathError::PathEscapesBoundary`: Guard against race conditions that move the directory.
296 ///
297 /// # Examples
298 ///
299 /// ```rust
300 /// # use strict_path::{PathBoundary, StrictPath};
301 /// let data_dir: PathBoundary = PathBoundary::try_new_create("./data")?;
302 /// let data_path: StrictPath = data_dir.into_strictpath()?;
303 /// assert!(data_path.is_dir());
304 /// # Ok::<_, Box<dyn std::error::Error>>(())
305 /// ```
306 #[must_use = "into_strictpath() consumes the PathBoundary — use the returned StrictPath for I/O operations"]
307 #[inline]
308 pub fn into_strictpath(self) -> Result<StrictPath<Marker>> {
309 let root_history = self.path.clone();
310 let validated = PathHistory::<Raw>::new(root_history.as_ref().to_path_buf())
311 .canonicalize()?
312 .boundary_check(root_history.as_ref())?;
313 Ok(StrictPath::new(Arc::new(self), validated))
314 }
315
316 /// Returns the canonicalized PathBoundary directory path. Kept crate-private to avoid leaking raw path.
317 #[inline]
318 pub(crate) fn path(&self) -> &Path {
319 self.path.as_ref()
320 }
321
322 /// Internal: returns the canonicalized PathHistory of the PathBoundary directory for boundary checks.
323 #[cfg(feature = "virtual-path")]
324 #[inline]
325 pub(crate) fn stated_path(&self) -> &PathHistory<((Raw, Canonicalized), Exists)> {
326 &self.path
327 }
328
329 /// Returns true if the PathBoundary directory exists.
330 ///
331 /// This is always true for a constructed PathBoundary, but we query the filesystem for robustness.
332 #[must_use]
333 #[inline]
334 pub fn exists(&self) -> bool {
335 self.path.exists()
336 }
337
338 /// Return the boundary directory path as `&OsStr` for unavoidable third-party `AsRef<Path>` interop (no allocation).
339 #[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"]
340 #[inline]
341 pub fn interop_path(&self) -> &std::ffi::OsStr {
342 self.path.as_os_str()
343 }
344
345 /// Returns a Display wrapper that shows the PathBoundary directory system path.
346 #[must_use = "strictpath_display() shows the real system path (admin/debug use) — for user-facing output prefer VirtualPath::virtualpath_display() which hides internal paths"]
347 #[inline]
348 pub fn strictpath_display(&self) -> std::path::Display<'_> {
349 self.path().display()
350 }
351
352 /// Return filesystem metadata for the boundary directory.
353 #[inline]
354 pub fn metadata(&self) -> std::io::Result<std::fs::Metadata> {
355 std::fs::metadata(self.path())
356 }
357
358 /// Create a symbolic link at `link_path` pointing to this boundary's directory.
359 ///
360 pub fn strict_symlink<P: AsRef<Path>>(&self, link_path: P) -> std::io::Result<()> {
361 let root = self
362 .clone()
363 .into_strictpath()
364 .map_err(std::io::Error::other)?;
365
366 root.strict_symlink(link_path)
367 }
368
369 /// Create a hard link at `link_path` pointing to this boundary's directory.
370 ///
371 /// Accepts the same `link_path: impl AsRef<Path>` parameter as `strict_symlink` and returns `io::Result<()>`.
372 pub fn strict_hard_link<P: AsRef<Path>>(&self, link_path: P) -> std::io::Result<()> {
373 let root = self
374 .clone()
375 .into_strictpath()
376 .map_err(std::io::Error::other)?;
377
378 root.strict_hard_link(link_path)
379 }
380
381 /// Create a Windows NTFS directory junction at `link_path` pointing to this boundary's directory.
382 ///
383 /// - Windows-only and behind the `junctions` crate feature.
384 /// - Junctions are directory-only.
385 #[cfg(all(windows, feature = "junctions"))]
386 pub fn strict_junction<P: AsRef<Path>>(&self, link_path: P) -> std::io::Result<()> {
387 let root = self
388 .clone()
389 .into_strictpath()
390 .map_err(std::io::Error::other)?;
391
392 root.strict_junction(link_path)
393 }
394
395 /// Read directory entries under the boundary directory (discovery only).
396 #[inline]
397 pub fn read_dir(&self) -> std::io::Result<std::fs::ReadDir> {
398 std::fs::read_dir(self.path())
399 }
400
401 /// Iterate directory entries under the boundary, yielding validated `StrictPath` values.
402 ///
403 /// Unlike `read_dir()` which returns raw `std::fs::DirEntry` values requiring manual
404 /// re-validation, this method yields `StrictPath` entries directly. Each entry is
405 /// automatically validated through `strict_join()` so you can use it immediately
406 /// for I/O operations without additional validation.
407 ///
408 /// # Examples
409 ///
410 /// ```rust
411 /// use strict_path::PathBoundary;
412 ///
413 /// # let temp = tempfile::tempdir()?;
414 /// let data_dir: PathBoundary = PathBoundary::try_new(temp.path())?;
415 /// # data_dir.strict_join("file.txt")?.write("test")?;
416 ///
417 /// // Auto-validated iteration - no manual re-join needed!
418 /// for entry in data_dir.strict_read_dir()? {
419 /// let child = entry?;
420 /// println!("Found: {}", child.strictpath_display());
421 /// }
422 /// # Ok::<_, Box<dyn std::error::Error>>(())
423 /// ```
424 #[inline]
425 pub fn strict_read_dir(&self) -> std::io::Result<BoundaryReadDir<'_, Marker>> {
426 Ok(BoundaryReadDir {
427 inner: std::fs::read_dir(self.path())?,
428 boundary: self,
429 })
430 }
431
432 /// Remove the boundary directory (non-recursive); fails if not empty.
433 #[inline]
434 pub fn remove_dir(&self) -> std::io::Result<()> {
435 std::fs::remove_dir(self.path())
436 }
437
438 /// Recursively remove the boundary directory and its contents.
439 #[inline]
440 pub fn remove_dir_all(&self) -> std::io::Result<()> {
441 std::fs::remove_dir_all(self.path())
442 }
443
444 /// Convert this boundary into a `VirtualRoot` for virtual path operations.
445 #[must_use = "virtualize() consumes self — use the returned VirtualRoot for virtual path operations (.virtual_join(), .into_virtualpath())"]
446 #[cfg(feature = "virtual-path")]
447 #[inline]
448 pub fn virtualize(self) -> crate::VirtualRoot<Marker> {
449 crate::VirtualRoot {
450 root: self,
451 _marker: PhantomData,
452 }
453 }
454
455 // Note: Do not add new crate-private helpers unless necessary; use existing flows.
456}
457
458impl<Marker> std::fmt::Debug for PathBoundary<Marker> {
459 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
460 f.debug_struct("PathBoundary")
461 .field("path", &self.path.as_ref())
462 .field("marker", &std::any::type_name::<Marker>())
463 .finish()
464 }
465}
466
467impl<Marker> std::str::FromStr for PathBoundary<Marker> {
468 type Err = crate::StrictPathError;
469
470 /// Forwards to [`try_new_create`](Self::try_new_create): creates the
471 /// target directory if missing, then canonicalizes and validates it as a
472 /// directory.
473 ///
474 /// Untrusted per-request paths (filenames, archive entries, CLI
475 /// free-form args) are not `FromStr` input — validate those via
476 /// [`strict_join`](Self::strict_join) on a pre-constructed boundary.
477 ///
478 /// ```rust
479 /// # use strict_path::PathBoundary;
480 /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
481 /// # let tmp = tempfile::tempdir()?;
482 /// # let p = tmp.path().join("data_dir");
483 /// # let p = p.to_string_lossy().into_owned();
484 /// let data_dir: PathBoundary<()> = p.parse()?;
485 /// assert!(data_dir.exists());
486 /// # Ok(())
487 /// # }
488 /// ```
489 #[inline]
490 fn from_str(path: &str) -> std::result::Result<Self, Self::Err> {
491 Self::try_new_create(path)
492 }
493}
494
495// ============================================================
496// BoundaryReadDir — Iterator for validated directory entries
497// ============================================================
498
499/// Iterator over directory entries that yields validated `StrictPath` values.
500///
501/// Created by `PathBoundary::strict_read_dir()`. Each iteration automatically validates
502/// the directory entry through `strict_join()`, so you get `StrictPath` values directly
503/// instead of raw `std::fs::DirEntry` that would require manual re-validation.
504///
505/// # Examples
506///
507/// ```rust
508/// # use strict_path::PathBoundary;
509/// # let temp = tempfile::tempdir()?;
510/// let data_dir: PathBoundary = PathBoundary::try_new(temp.path())?;
511/// # data_dir.strict_join("readme.md")?.write("# Docs")?;
512/// for entry in data_dir.strict_read_dir()? {
513/// let child = entry?;
514/// if child.is_file() {
515/// println!("File: {}", child.strictpath_display());
516/// }
517/// }
518/// # Ok::<_, Box<dyn std::error::Error>>(())
519/// ```
520pub struct BoundaryReadDir<'a, Marker> {
521 inner: std::fs::ReadDir,
522 boundary: &'a PathBoundary<Marker>,
523}
524
525impl<Marker> std::fmt::Debug for BoundaryReadDir<'_, Marker> {
526 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
527 f.debug_struct("BoundaryReadDir")
528 .field("boundary", &self.boundary.strictpath_display())
529 .finish_non_exhaustive()
530 }
531}
532
533impl<Marker: Clone> Iterator for BoundaryReadDir<'_, Marker> {
534 type Item = std::io::Result<crate::StrictPath<Marker>>;
535
536 fn next(&mut self) -> Option<Self::Item> {
537 match self.inner.next()? {
538 Ok(entry) => {
539 let file_name = entry.file_name();
540 match self.boundary.strict_join(file_name) {
541 Ok(strict_path) => Some(Ok(strict_path)),
542 Err(e) => Some(Err(std::io::Error::new(std::io::ErrorKind::InvalidData, e))),
543 }
544 }
545 Err(e) => Some(Err(e)),
546 }
547 }
548}
549//