Skip to main content

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}