Skip to main content

zeph_common/
security.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Shared sandbox-boundary check for `allowed_paths`-style path validation.
5//!
6//! Extracted from the near-identical `validate_path` bodies in
7//! `zeph-tools::file::FileExecutor` and `zeph-tools::diagnostics::DiagnosticsExecutor` (#6032
8//! SEC-2) so every caller enforcing an `allowed_paths` sandbox — including
9//! `zeph-tools::cwd::resolve_and_set_cwd`, the third caller this module was extracted for —
10//! shares one canonical `starts_with`-against-allowed-roots check instead of three
11//! textually-similar-but-independently-maintained copies.
12//!
13//! Deliberately does **not** own path *resolution* strategy (tilde-expansion, relative-path
14//! joining, or symlink-tolerant canonicalization of a not-yet-existing target): callers differ
15//! legitimately there — `FileExecutor` must tolerate a nonexistent target (writing a new
16//! file), while `DiagnosticsExecutor` and `resolve_and_set_cwd` require the target to already
17//! exist. Only the final containment check — the actual security invariant — is shared.
18
19use std::io;
20use std::path::{Path, PathBuf};
21
22/// Returns `true` if `canonical` is contained within (or equal to) at least one of
23/// `allowed_paths`.
24///
25/// `canonical` must already be canonicalized (symlinks resolved) by the caller — this
26/// function performs no filesystem access itself, so it is safe to call on a path that does
27/// not fully exist yet (e.g. `FileExecutor`'s ancestor-resolved-but-not-yet-created target).
28#[must_use]
29pub fn is_path_within(canonical: &Path, allowed_paths: &[PathBuf]) -> bool {
30    allowed_paths.iter().any(|a| canonical.starts_with(a))
31}
32
33/// Canonicalize `path` (which must already exist) and verify it falls within one of
34/// `allowed_paths`.
35///
36/// Convenience wrapper around [`is_path_within`] for the common case of a target that must
37/// already exist (e.g. a directory to `cd` into, or a file to run diagnostics against) —
38/// callers whose target may not yet exist (e.g. a new file being written) must canonicalize
39/// via their own symlink-tolerant strategy and call [`is_path_within`] directly instead.
40///
41/// # Errors
42///
43/// Returns [`io::ErrorKind::NotFound`] (via [`Path::canonicalize`]) if `path` does not exist,
44/// or [`io::ErrorKind::PermissionDenied`] if the canonicalized path falls outside every entry
45/// in `allowed_paths`.
46///
47/// # Examples
48///
49/// ```
50/// use zeph_common::security::validate_path_within;
51///
52/// let dir = tempfile::tempdir().unwrap();
53/// // Callers canonicalize `allowed_paths` up front (as `FileExecutor::new` does) so this
54/// // comparison is not defeated by a symlinked temp root (e.g. macOS `/tmp` -> `/private/tmp`).
55/// let allowed = vec![dir.path().canonicalize().unwrap()];
56/// let result = validate_path_within(dir.path(), &allowed);
57/// assert!(result.is_ok());
58/// ```
59pub fn validate_path_within(path: &Path, allowed_paths: &[PathBuf]) -> io::Result<PathBuf> {
60    let canonical = path.canonicalize()?;
61    if !is_path_within(&canonical, allowed_paths) {
62        return Err(io::Error::new(
63            io::ErrorKind::PermissionDenied,
64            format!(
65                "path '{}' is outside the allowed sandbox",
66                canonical.display()
67            ),
68        ));
69    }
70    Ok(canonical)
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    #[test]
78    fn is_path_within_true_for_exact_match() {
79        let dir = tempfile::tempdir().unwrap();
80        let allowed = vec![dir.path().to_path_buf()];
81        assert!(is_path_within(dir.path(), &allowed));
82    }
83
84    #[test]
85    fn is_path_within_true_for_nested_path() {
86        let dir = tempfile::tempdir().unwrap();
87        let allowed = vec![dir.path().to_path_buf()];
88        let nested = dir.path().join("a").join("b");
89        assert!(is_path_within(&nested, &allowed));
90    }
91
92    #[test]
93    fn is_path_within_false_for_sibling_outside_root() {
94        let dir = tempfile::tempdir().unwrap();
95        let sibling = tempfile::tempdir().unwrap();
96        let allowed = vec![dir.path().to_path_buf()];
97        assert!(!is_path_within(sibling.path(), &allowed));
98    }
99
100    #[test]
101    fn is_path_within_true_when_any_of_multiple_roots_matches() {
102        let dir_a = tempfile::tempdir().unwrap();
103        let dir_b = tempfile::tempdir().unwrap();
104        let allowed = vec![dir_a.path().to_path_buf(), dir_b.path().to_path_buf()];
105        assert!(is_path_within(dir_b.path(), &allowed));
106    }
107
108    #[test]
109    fn validate_path_within_ok_for_existing_path_inside_root() {
110        let dir = tempfile::tempdir().unwrap();
111        // Canonicalize the allowed root up front — mirrors `FileExecutor::new`/
112        // `DiagnosticsExecutor::new`'s real construction pattern, and avoids a false
113        // rejection on platforms where the tempdir root is itself a symlink (e.g. macOS
114        // `/tmp` -> `/private/tmp`).
115        let allowed = vec![dir.path().canonicalize().unwrap()];
116        let result = validate_path_within(dir.path(), &allowed);
117        assert!(result.is_ok());
118    }
119
120    #[test]
121    fn validate_path_within_rejects_path_outside_root() {
122        let dir = tempfile::tempdir().unwrap();
123        let outside = tempfile::tempdir().unwrap();
124        let allowed = vec![dir.path().canonicalize().unwrap()];
125        let result = validate_path_within(outside.path(), &allowed);
126        let err = result.unwrap_err();
127        assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
128    }
129
130    #[test]
131    fn validate_path_within_errors_on_nonexistent_path() {
132        let dir = tempfile::tempdir().unwrap();
133        let allowed = vec![dir.path().canonicalize().unwrap()];
134        let missing = dir.path().join("does-not-exist");
135        let result = validate_path_within(&missing, &allowed);
136        let err = result.unwrap_err();
137        assert_eq!(err.kind(), io::ErrorKind::NotFound);
138    }
139}