Skip to main content

strict_path/error/
mod.rs

1//! Define error types and helpers for boundary creation and strict/virtual path validation.
2//!
3//! Exposes the crate-wide error enum `StrictPathError`, which captures boundary creation
4//! failures, path resolution errors, and boundary escape attempts. These errors are surfaced
5//! by public constructors and join operations throughout the crate.
6use crate::sanitize::sanitize_untrusted_display_text;
7use std::error::Error;
8use std::fmt;
9use std::path::{Path, PathBuf};
10
11/// Maximum characters shown per path in error messages. Paths longer than this are
12/// truncated with `...` in the middle to keep diagnostics readable without hiding
13/// the meaningful prefix and suffix (drive letter / filename).
14const MAX_ERROR_PATH_LEN: usize = 256;
15
16// Internal helper: render error-friendly path display (truncate long values).
17pub(crate) fn truncate_path_display(path: &Path, max_len: usize) -> String {
18    let path_str = sanitize_untrusted_display_text(&path.to_string_lossy());
19    let char_count = path_str.chars().count();
20    if char_count <= max_len {
21        return path_str;
22    }
23    let keep = max_len.saturating_sub(5) / 2;
24    let start: String = path_str.chars().take(keep).collect();
25    let mut tail_chars: Vec<char> = path_str.chars().rev().take(keep).collect();
26    tail_chars.reverse();
27    let end: String = tail_chars.into_iter().collect();
28    format!("{start}...{end}")
29}
30
31/// Errors produced by boundary creation and strict/virtual path validation.
32///
33/// Returned by operations that construct `PathBoundary`/`VirtualRoot` or compose
34/// `StrictPath`/`VirtualPath` via joins. Each variant carries enough context for
35/// actionable diagnostics while avoiding leaking unbounded path data into messages.
36#[derive(Debug)]
37#[must_use = "this error indicates a path validation failure — handle it to detect path traversal attacks or invalid boundaries"]
38pub enum StrictPathError {
39    /// The boundary directory is invalid (missing, not a directory, or I/O error).
40    InvalidRestriction {
41        restriction: PathBuf,
42        source: std::io::Error,
43    },
44    /// The attempted path resolves outside the `PathBoundary` — a traversal attack was blocked.
45    PathEscapesBoundary {
46        attempted_path: PathBuf,
47        restriction_boundary: PathBuf,
48    },
49    /// Canonicalization or resolution failed for the given path.
50    PathResolutionError {
51        path: PathBuf,
52        source: std::io::Error,
53    },
54}
55
56impl StrictPathError {
57    // Internal helper: construct `InvalidRestriction`.
58    #[inline]
59    pub(crate) fn invalid_restriction(restriction: PathBuf, source: std::io::Error) -> Self {
60        Self::InvalidRestriction {
61            restriction,
62            source,
63        }
64    }
65    // Internal helper: construct `PathEscapesBoundary`.
66    #[inline]
67    pub(crate) fn path_escapes_boundary(
68        attempted_path: PathBuf,
69        restriction_boundary: PathBuf,
70    ) -> Self {
71        Self::PathEscapesBoundary {
72            attempted_path,
73            restriction_boundary,
74        }
75    }
76    // Internal helper: construct `PathResolutionError`.
77    #[inline]
78    pub(crate) fn path_resolution_error(path: PathBuf, source: std::io::Error) -> Self {
79        Self::PathResolutionError { path, source }
80    }
81}
82
83impl fmt::Display for StrictPathError {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        match self {
86            StrictPathError::InvalidRestriction {
87                restriction,
88                source,
89            } => {
90                let source_display = sanitize_untrusted_display_text(&source.to_string());
91                write!(
92                    f,
93                    "Invalid PathBoundary: '{}' is not a valid boundary directory ({source_display}). \
94                     Ensure the path points to an existing directory, or use try_new_create() to auto-create it.",
95                    truncate_path_display(restriction, MAX_ERROR_PATH_LEN)
96                )
97            }
98            StrictPathError::PathEscapesBoundary {
99                attempted_path,
100                restriction_boundary,
101            } => {
102                let truncated_attempted = truncate_path_display(attempted_path, MAX_ERROR_PATH_LEN);
103                let truncated_boundary =
104                    truncate_path_display(restriction_boundary, MAX_ERROR_PATH_LEN);
105                write!(
106                    f,
107                    "Path escapes boundary: '{truncated_attempted}' resolves outside restriction boundary \
108                     '{truncated_boundary}' — this path traversal attempt was blocked. \
109                     Validate untrusted input through strict_join()/virtual_join() which prevents escapes."
110                )
111            }
112            StrictPathError::PathResolutionError { path, source } => {
113                let source_display = sanitize_untrusted_display_text(&source.to_string());
114                write!(
115                    f,
116                    "Cannot resolve path: '{}' ({source_display}). \
117                     Ensure the target exists and is accessible, or create parent directories first.",
118                    truncate_path_display(path, MAX_ERROR_PATH_LEN)
119                )
120            }
121        }
122    }
123}
124
125impl Error for StrictPathError {
126    fn source(&self) -> Option<&(dyn Error + 'static)> {
127        match self {
128            StrictPathError::InvalidRestriction { source, .. }
129            | StrictPathError::PathResolutionError { source, .. } => Some(source),
130            StrictPathError::PathEscapesBoundary { .. } => None,
131        }
132    }
133}
134
135#[cfg(test)]
136mod tests;