1use std::collections::HashSet;
4use std::path::{Path, PathBuf};
5
6#[derive(Debug, Clone, PartialEq)]
8pub enum PathPolicy {
9 DenyAll,
11 AllowList(HashSet<PathBuf>),
13 DenyList(HashSet<PathBuf>),
15 AllowAll,
17}
18
19impl Default for PathPolicy {
20 fn default() -> Self {
21 PathPolicy::DenyAll
22 }
23}
24
25impl PathPolicy {
26 pub fn is_allowed(&self, path: &Path) -> bool {
28 match self {
29 PathPolicy::DenyAll => false,
30 PathPolicy::AllowAll => true,
31 PathPolicy::AllowList(allowed) => {
32 let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
34 allowed.iter().any(|allowed_path| {
35 canonical.starts_with(allowed_path)
36 })
37 }
38 PathPolicy::DenyList(denied) => {
39 let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
40 !denied.iter().any(|denied_path| {
41 canonical.starts_with(denied_path)
42 })
43 }
44 }
45 }
46
47 pub fn allow<I, P>(paths: I) -> Self
49 where
50 I: IntoIterator<Item = P>,
51 P: Into<PathBuf>,
52 {
53 PathPolicy::AllowList(paths.into_iter().map(Into::into).collect())
54 }
55
56 pub fn deny<I, P>(paths: I) -> Self
58 where
59 I: IntoIterator<Item = P>,
60 P: Into<PathBuf>,
61 {
62 PathPolicy::DenyList(paths.into_iter().map(Into::into).collect())
63 }
64}
65
66#[derive(Debug, Clone, PartialEq)]
68pub enum NetPolicy {
69 DenyAll,
71 AllowList(HashSet<String>),
73 DenyList(HashSet<String>),
75 AllowAll,
77}
78
79impl Default for NetPolicy {
80 fn default() -> Self {
81 NetPolicy::DenyAll
82 }
83}
84
85impl NetPolicy {
86 pub fn is_allowed(&self, host: &str) -> bool {
88 let host_lower = host.to_lowercase();
89 match self {
90 NetPolicy::DenyAll => false,
91 NetPolicy::AllowAll => true,
92 NetPolicy::AllowList(allowed) => {
93 allowed.iter().any(|a| {
94 let a_lower = a.to_lowercase();
95 if a_lower.starts_with("*.") {
97 let suffix = &a_lower[1..];
98 host_lower.ends_with(suffix) || host_lower == &a_lower[2..]
99 } else {
100 host_lower == a_lower
101 }
102 })
103 }
104 NetPolicy::DenyList(denied) => {
105 !denied.iter().any(|d| {
106 let d_lower = d.to_lowercase();
107 if d_lower.starts_with("*.") {
108 let suffix = &d_lower[1..];
109 host_lower.ends_with(suffix) || host_lower == &d_lower[2..]
110 } else {
111 host_lower == d_lower
112 }
113 })
114 }
115 }
116 }
117
118 pub fn allow<I, S>(hosts: I) -> Self
120 where
121 I: IntoIterator<Item = S>,
122 S: Into<String>,
123 {
124 NetPolicy::AllowList(hosts.into_iter().map(Into::into).collect())
125 }
126
127 pub fn deny<I, S>(hosts: I) -> Self
129 where
130 I: IntoIterator<Item = S>,
131 S: Into<String>,
132 {
133 NetPolicy::DenyList(hosts.into_iter().map(Into::into).collect())
134 }
135}
136
137#[derive(Debug, Clone, Default)]
139pub struct SandboxConfig {
140 pub fs_read: PathPolicy,
142 pub fs_write: PathPolicy,
144 pub net_outgoing: NetPolicy,
146 pub net_incoming: NetPolicy,
148 pub env_vars: Option<HashSet<String>>,
150 pub working_dir: Option<PathBuf>,
152 pub isolate_temp: bool,
154}
155
156impl SandboxConfig {
157 pub fn locked() -> Self {
159 Self {
160 fs_read: PathPolicy::DenyAll,
161 fs_write: PathPolicy::DenyAll,
162 net_outgoing: NetPolicy::DenyAll,
163 net_incoming: NetPolicy::DenyAll,
164 env_vars: Some(HashSet::new()),
165 working_dir: None,
166 isolate_temp: true,
167 }
168 }
169
170 pub fn permissive() -> Self {
172 Self {
173 fs_read: PathPolicy::AllowAll,
174 fs_write: PathPolicy::AllowAll,
175 net_outgoing: NetPolicy::AllowAll,
176 net_incoming: NetPolicy::AllowAll,
177 env_vars: None,
178 working_dir: None,
179 isolate_temp: false,
180 }
181 }
182
183 pub fn with_read_paths<I, P>(mut self, paths: I) -> Self
185 where
186 I: IntoIterator<Item = P>,
187 P: Into<PathBuf>,
188 {
189 self.fs_read = PathPolicy::allow(paths);
190 self
191 }
192
193 pub fn with_write_paths<I, P>(mut self, paths: I) -> Self
195 where
196 I: IntoIterator<Item = P>,
197 P: Into<PathBuf>,
198 {
199 self.fs_write = PathPolicy::allow(paths);
200 self
201 }
202
203 pub fn with_allowed_hosts<I, S>(mut self, hosts: I) -> Self
205 where
206 I: IntoIterator<Item = S>,
207 S: Into<String>,
208 {
209 self.net_outgoing = NetPolicy::allow(hosts);
210 self
211 }
212
213 pub fn with_env_vars<I, S>(mut self, vars: I) -> Self
215 where
216 I: IntoIterator<Item = S>,
217 S: Into<String>,
218 {
219 self.env_vars = Some(vars.into_iter().map(Into::into).collect());
220 self
221 }
222
223 pub fn with_working_dir<P: Into<PathBuf>>(mut self, path: P) -> Self {
225 self.working_dir = Some(path.into());
226 self
227 }
228
229 pub fn with_temp_isolation(mut self) -> Self {
231 self.isolate_temp = true;
232 self
233 }
234
235 pub fn can_read(&self, path: &Path) -> bool {
237 self.fs_read.is_allowed(path)
238 }
239
240 pub fn can_write(&self, path: &Path) -> bool {
242 self.fs_write.is_allowed(path)
243 }
244
245 pub fn can_connect(&self, host: &str) -> bool {
247 self.net_outgoing.is_allowed(host)
248 }
249
250 pub fn can_access_env(&self, name: &str) -> bool {
252 match &self.env_vars {
253 None => true,
254 Some(allowed) => allowed.contains(name),
255 }
256 }
257}
258
259#[derive(Debug)]
261pub struct Sandbox {
262 config: SandboxConfig,
263 temp_dir: Option<PathBuf>,
264}
265
266impl Sandbox {
267 pub fn new(config: SandboxConfig) -> crate::Result<Self> {
269 let temp_dir = if config.isolate_temp {
270 let dir = std::env::temp_dir().join(format!(
272 "fusabi-sandbox-{}",
273 std::process::id()
274 ));
275 std::fs::create_dir_all(&dir)?;
276 Some(dir)
277 } else {
278 None
279 };
280
281 Ok(Self { config, temp_dir })
282 }
283
284 pub fn config(&self) -> &SandboxConfig {
286 &self.config
287 }
288
289 pub fn temp_dir(&self) -> Option<&Path> {
291 self.temp_dir.as_deref()
292 }
293
294 pub fn check_read(&self, path: &Path) -> crate::Result<()> {
296 if self.config.can_read(path) {
297 Ok(())
298 } else {
299 Err(crate::Error::sandbox_violation(format!(
300 "read access denied: {}",
301 path.display()
302 )))
303 }
304 }
305
306 pub fn check_write(&self, path: &Path) -> crate::Result<()> {
308 if self.config.can_write(path) {
309 Ok(())
310 } else {
311 Err(crate::Error::sandbox_violation(format!(
312 "write access denied: {}",
313 path.display()
314 )))
315 }
316 }
317
318 pub fn check_connect(&self, host: &str) -> crate::Result<()> {
320 if self.config.can_connect(host) {
321 Ok(())
322 } else {
323 Err(crate::Error::sandbox_violation(format!(
324 "network access denied: {}",
325 host
326 )))
327 }
328 }
329
330 pub fn check_env(&self, name: &str) -> crate::Result<()> {
332 if self.config.can_access_env(name) {
333 Ok(())
334 } else {
335 Err(crate::Error::sandbox_violation(format!(
336 "environment variable access denied: {}",
337 name
338 )))
339 }
340 }
341}
342
343impl Drop for Sandbox {
344 fn drop(&mut self) {
345 if let Some(ref dir) = self.temp_dir {
347 let _ = std::fs::remove_dir_all(dir);
348 }
349 }
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355
356 #[test]
357 fn test_path_policy_deny_all() {
358 let policy = PathPolicy::DenyAll;
359 assert!(!policy.is_allowed(Path::new("/any/path")));
360 }
361
362 #[test]
363 fn test_path_policy_allow_all() {
364 let policy = PathPolicy::AllowAll;
365 assert!(policy.is_allowed(Path::new("/any/path")));
366 }
367
368 #[test]
369 fn test_path_policy_allowlist() {
370 let policy = PathPolicy::allow(["/tmp", "/home/user/data"]);
371 assert!(!policy.is_allowed(Path::new("/etc/passwd")));
374 }
375
376 #[test]
377 fn test_net_policy_deny_all() {
378 let policy = NetPolicy::DenyAll;
379 assert!(!policy.is_allowed("example.com"));
380 }
381
382 #[test]
383 fn test_net_policy_allow_all() {
384 let policy = NetPolicy::AllowAll;
385 assert!(policy.is_allowed("example.com"));
386 }
387
388 #[test]
389 fn test_net_policy_allowlist() {
390 let policy = NetPolicy::allow(["example.com", "*.trusted.org"]);
391 assert!(policy.is_allowed("example.com"));
392 assert!(policy.is_allowed("api.trusted.org"));
393 assert!(policy.is_allowed("trusted.org"));
394 assert!(!policy.is_allowed("malicious.com"));
395 }
396
397 #[test]
398 fn test_net_policy_denylist() {
399 let policy = NetPolicy::deny(["evil.com", "*.malware.net"]);
400 assert!(policy.is_allowed("example.com"));
401 assert!(!policy.is_allowed("evil.com"));
402 assert!(!policy.is_allowed("download.malware.net"));
403 }
404
405 #[test]
406 fn test_sandbox_config_locked() {
407 let config = SandboxConfig::locked();
408 assert!(!config.can_read(Path::new("/etc/passwd")));
409 assert!(!config.can_write(Path::new("/tmp/file")));
410 assert!(!config.can_connect("example.com"));
411 assert!(!config.can_access_env("PATH"));
412 }
413
414 #[test]
415 fn test_sandbox_config_permissive() {
416 let config = SandboxConfig::permissive();
417 assert!(config.can_read(Path::new("/etc/passwd")));
418 assert!(config.can_connect("example.com"));
419 assert!(config.can_access_env("PATH"));
420 }
421
422 #[test]
423 fn test_sandbox_config_builder() {
424 let config = SandboxConfig::locked()
425 .with_allowed_hosts(["api.example.com"])
426 .with_env_vars(["HOME", "USER"]);
427
428 assert!(config.can_connect("api.example.com"));
429 assert!(!config.can_connect("other.com"));
430 assert!(config.can_access_env("HOME"));
431 assert!(!config.can_access_env("SECRET"));
432 }
433}