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}