zeph_common/path_guard.rs
1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Classifier for the "relative paths only, no `..`" policy used by every
5//! attachment-loading entry point that has no sandbox/`allowed_paths` configuration
6//! available to it (currently `/image`).
7//!
8//! This is deliberately **not** a sandbox: it has no notion of allowed roots and does
9//! not canonicalize or touch the filesystem. It only classifies a path string as
10//! absolute, `..`-traversing, or acceptable, so every caller enforcing "relative paths
11//! only" applies the exact same rule and reports the exact same classification —
12//! closing the drift risk of three independent, textually-similar-but-not-identical
13//! checks (one such check previously missed the Windows leading-`/` guard the other
14//! two had).
15
16use std::path::{Component, Path};
17
18/// Outcome of classifying a path against the "relative, no `..`" policy.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum PathRejection {
21 /// Path is relative and contains no `..` component — acceptable.
22 Allowed,
23 /// Path is absolute, or looks absolute via a leading `/`.
24 ///
25 /// `Path::is_absolute` is `false` on Windows for Unix-style paths like `/etc/passwd`
26 /// (no drive letter), so the leading-`/` string check is required in addition to it.
27 Absolute,
28 /// Path contains a `..` (`Component::ParentDir`) component.
29 Traversal,
30}
31
32/// Classify `path` against the "relative paths only, no `..`" policy.
33///
34/// Checks the absolute case before the traversal case, so a path that is both absolute
35/// and contains `..` is reported as [`PathRejection::Absolute`].
36///
37/// # Examples
38///
39/// ```
40/// use zeph_common::path_guard::{PathRejection, classify_relative_path};
41///
42/// assert_eq!(classify_relative_path("photo.jpg"), PathRejection::Allowed);
43/// assert_eq!(classify_relative_path("/etc/passwd"), PathRejection::Absolute);
44/// assert_eq!(classify_relative_path("../../etc/passwd"), PathRejection::Traversal);
45/// ```
46#[must_use]
47pub fn classify_relative_path(path: &str) -> PathRejection {
48 let p = Path::new(path);
49 if p.is_absolute() || path.starts_with('/') {
50 return PathRejection::Absolute;
51 }
52 if p.components().any(|c| c == Component::ParentDir) {
53 return PathRejection::Traversal;
54 }
55 PathRejection::Allowed
56}
57
58#[cfg(test)]
59mod tests {
60 use super::*;
61
62 #[test]
63 fn allows_plain_relative_path() {
64 assert_eq!(classify_relative_path("photo.jpg"), PathRejection::Allowed);
65 }
66
67 #[test]
68 fn allows_nested_relative_path() {
69 assert_eq!(
70 classify_relative_path("sub/dir/photo.jpg"),
71 PathRejection::Allowed
72 );
73 }
74
75 #[test]
76 fn rejects_absolute_unix_path() {
77 assert_eq!(
78 classify_relative_path("/etc/passwd"),
79 PathRejection::Absolute
80 );
81 }
82
83 #[test]
84 fn rejects_leading_slash_even_when_is_absolute_would_miss_it() {
85 // A leading '/' must be caught even on platforms where `Path::is_absolute()`
86 // alone would not flag it (Windows).
87 assert_eq!(
88 classify_relative_path("/tmp/screenshot.png"),
89 PathRejection::Absolute
90 );
91 }
92
93 #[test]
94 fn rejects_parent_dir_traversal() {
95 assert_eq!(
96 classify_relative_path("../../etc/passwd"),
97 PathRejection::Traversal
98 );
99 }
100
101 #[test]
102 fn rejects_nested_parent_dir_traversal() {
103 assert_eq!(
104 classify_relative_path("sub/../../etc/passwd"),
105 PathRejection::Traversal
106 );
107 }
108
109 #[test]
110 fn absolute_takes_priority_over_traversal() {
111 assert_eq!(
112 classify_relative_path("/etc/../etc/passwd"),
113 PathRejection::Absolute
114 );
115 }
116
117 #[test]
118 fn allows_single_dot_component() {
119 assert_eq!(
120 classify_relative_path("./photo.jpg"),
121 PathRejection::Allowed
122 );
123 }
124}