1use std::collections::{BTreeMap, BTreeSet};
2use std::path::PathBuf;
3
4use thiserror::Error;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct UntrustedExecRequest {
10 pub program: PathBuf,
11 pub args: Vec<String>,
12 pub env: BTreeMap<String, String>,
13 pub cwd: Option<PathBuf>,
14}
15
16impl UntrustedExecRequest {
17 pub fn validate(self, policy: &ExecPolicy) -> Result<ValidatedExecRequest, SandboxError> {
18 policy.validate(self)
19 }
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct ValidatedExecRequest {
25 pub program: PathBuf,
26 pub args: Vec<String>,
27 pub env: BTreeMap<String, String>,
28 pub cwd: Option<PathBuf>,
29}
30
31pub trait Sandbox: Send + Sync {
35 fn prepare(&self, request: &ValidatedExecRequest) -> Result<SandboxPlan, SandboxError>;
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct SandboxPlan {
42 pub program: PathBuf,
43 pub args: Vec<String>,
44 pub env: BTreeMap<String, String>,
45 pub cwd: Option<PathBuf>,
46}
47
48impl SandboxPlan {
49 pub fn passthrough(request: &ValidatedExecRequest) -> Self {
50 Self {
51 program: request.program.clone(),
52 args: request.args.clone(),
53 env: request.env.clone(),
54 cwd: request.cwd.clone(),
55 }
56 }
57}
58
59#[derive(Debug, Clone, Default, PartialEq, Eq)]
60pub struct ExecPolicy {
61 allowed_programs: BTreeSet<PathBuf>,
62 allowed_env: BTreeSet<String>,
63}
64
65impl ExecPolicy {
66 pub fn new() -> Self {
67 Self::default()
68 }
69
70 pub fn allow_program(mut self, path: impl Into<PathBuf>) -> Self {
71 self.allowed_programs.insert(path.into());
72 self
73 }
74
75 pub fn allow_env(mut self, key: impl Into<String>) -> Self {
76 self.allowed_env.insert(key.into());
77 self
78 }
79
80 pub fn validate(
81 &self,
82 request: UntrustedExecRequest,
83 ) -> Result<ValidatedExecRequest, SandboxError> {
84 if !self
85 .allowed_programs
86 .iter()
87 .any(|allowed| allowed == &request.program)
88 {
89 return Err(SandboxError::ProgramNotAllowed(request.program));
90 }
91
92 if request
93 .args
94 .iter()
95 .any(|arg| contains_shell_metachar(arg) || contains_control_chars(arg))
96 {
97 return Err(SandboxError::UnsafeArgv);
98 }
99
100 if request
101 .env
102 .keys()
103 .any(|key| !self.allowed_env.contains(key))
104 {
105 let rejected = request
106 .env
107 .keys()
108 .find(|key| !self.allowed_env.contains(*key))
109 .cloned()
110 .unwrap_or_else(|| unreachable!("env key guaranteed by any check above"));
111 return Err(SandboxError::EnvNotAllowed(rejected));
112 }
113
114 if request
115 .env
116 .iter()
117 .any(|(key, value)| contains_control_chars(key) || contains_control_chars(value))
118 {
119 return Err(SandboxError::InvalidEnvEncoding);
120 }
121
122 if request.cwd.as_deref().is_some_and(|cwd| !cwd.is_absolute()) {
123 return Err(SandboxError::RelativeWorkingDirectory(
124 request
125 .cwd
126 .unwrap_or_else(|| unreachable!("cwd guaranteed by is_some_and check above")),
127 ));
128 }
129
130 Ok(ValidatedExecRequest {
131 program: request.program,
132 args: request.args,
133 env: request.env,
134 cwd: request.cwd,
135 })
136 }
137}
138
139#[derive(Debug, Error, Clone, PartialEq, Eq)]
140#[non_exhaustive]
141pub enum SandboxError {
142 #[error("program not allowed: {0}")]
143 ProgramNotAllowed(PathBuf),
144 #[error("argv contains unsafe shell metacharacters")]
145 UnsafeArgv,
146 #[error("env key not allowed: {0}")]
147 EnvNotAllowed(String),
148 #[error("env contains invalid control characters")]
149 InvalidEnvEncoding,
150 #[error("working directory must be absolute: {0}")]
151 RelativeWorkingDirectory(PathBuf),
152 #[error("sandbox backend error: {0}")]
153 Backend(String),
154}
155
156fn contains_shell_metachar(input: &str) -> bool {
157 input
158 .chars()
159 .any(|ch| matches!(ch, ';' | '|' | '&' | '$' | '`'))
160}
161
162fn contains_control_chars(input: &str) -> bool {
163 input.chars().any(|ch| ch.is_control() && ch != '\t')
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 #[test]
171 fn validation_accepts_allowed_program_and_env() {
172 let policy = ExecPolicy::new()
173 .allow_program("/usr/local/bin/gaze-hook")
174 .allow_env("MAIL_FROM");
175 let request = UntrustedExecRequest {
176 program: PathBuf::from("/usr/local/bin/gaze-hook"),
177 args: vec![
178 "send-email".to_string(),
179 format!("<{}>", ["Email", "1"].join("_")),
180 ],
181 env: BTreeMap::from([("MAIL_FROM".to_string(), "bot@example.invalid".to_string())]),
182 cwd: Some(PathBuf::from("/tmp")),
183 };
184
185 let validated = request.validate(&policy).expect("validated request");
186 assert_eq!(validated.program, PathBuf::from("/usr/local/bin/gaze-hook"));
187 assert_eq!(validated.args[1], format!("<{}>", ["Email", "1"].join("_")));
188 }
189
190 #[test]
191 fn validation_rejects_shell_metacharacters() {
192 let policy = ExecPolicy::new().allow_program("/usr/local/bin/gaze-hook");
193 let request = UntrustedExecRequest {
194 program: PathBuf::from("/usr/local/bin/gaze-hook"),
195 args: vec!["send-email;cat".to_string()],
196 env: BTreeMap::new(),
197 cwd: None,
198 };
199
200 assert_eq!(request.validate(&policy), Err(SandboxError::UnsafeArgv));
201 }
202}