1use serde::{Deserialize, Serialize};
19use std::path::{Path, PathBuf};
20use thiserror::Error;
21use tracing::info;
22
23#[allow(dead_code)]
26#[derive(Error, Debug)]
27pub enum SandboxError {
28 #[error("Path '{0}' is outside the sandbox")]
29 PathOutsideSandbox(String),
30
31 #[error("Sandbox not initialized: {0}")]
32 NotInitialized(String),
33
34 #[error("IO error: {0}")]
35 Io(#[from] std::io::Error),
36
37 #[error("Resource limit exceeded: {0}")]
38 ResourceLimit(String),
39
40 #[error("Network access denied by sandbox")]
41 NetworkDenied,
42}
43
44#[allow(dead_code)]
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct SandboxConfig {
50 #[serde(default = "default_workdir")]
52 pub workdir: String,
53
54 #[serde(default = "default_timeout")]
56 pub timeout_secs: u64,
57
58 #[serde(default = "default_max_output")]
60 pub max_output_bytes: usize,
61
62 #[serde(default = "default_max_write")]
64 pub max_write_bytes: usize,
65
66 #[serde(default)]
68 pub allow_network: bool,
69
70 #[serde(default)]
72 pub allowed_env_prefixes: Vec<String>,
73
74 #[serde(default = "default_true")]
76 pub create_workdir: bool,
77}
78
79impl Default for SandboxConfig {
80 fn default() -> Self {
81 Self {
82 workdir: default_workdir(),
83 timeout_secs: default_timeout(),
84 max_output_bytes: default_max_output(),
85 max_write_bytes: default_max_write(),
86 allow_network: false,
87 allowed_env_prefixes: vec![
88 "PATH".to_string(),
89 "HOME".to_string(),
90 "USER".to_string(),
91 "TMPDIR".to_string(),
92 "RAVENCLAWS_".to_string(),
93 ],
94 create_workdir: true,
95 }
96 }
97}
98
99fn default_workdir() -> String {
100 "/tmp/ravenclaws-sandbox".to_string()
101}
102
103fn default_timeout() -> u64 {
104 30
105}
106
107fn default_max_output() -> usize {
108 65536
109}
110
111fn default_max_write() -> usize {
112 1048576
113}
114
115fn default_true() -> bool {
116 true
117}
118
119#[allow(dead_code)]
123pub struct Sandbox {
124 config: SandboxConfig,
125 workdir: PathBuf,
126 initialized: bool,
127}
128
129#[allow(dead_code)]
130impl Sandbox {
131 pub fn new(config: SandboxConfig) -> Self {
133 let workdir = PathBuf::from(&config.workdir);
134 Self {
135 config,
136 workdir,
137 initialized: false,
138 }
139 }
140
141 pub fn new_default() -> Self {
143 Self::new(SandboxConfig::default())
144 }
145}
146
147impl Default for Sandbox {
148 fn default() -> Self {
149 Self::new(SandboxConfig::default())
150 }
151}
152
153#[allow(dead_code)]
154impl Sandbox {
155 pub async fn init(&mut self) -> Result<(), SandboxError> {
157 if self.config.create_workdir {
158 tokio::fs::create_dir_all(&self.workdir).await?;
159 info!(workdir = %self.workdir.display(), "Sandbox initialized");
160 }
161 self.initialized = true;
162 Ok(())
163 }
164
165 pub fn is_initialized(&self) -> bool {
167 self.initialized
168 }
169
170 pub fn workdir(&self) -> &Path {
172 &self.workdir
173 }
174
175 #[allow(dead_code)]
177 pub fn config(&self) -> &SandboxConfig {
178 &self.config
179 }
180
181 pub fn resolve_path(&self, path: &str) -> Result<PathBuf, SandboxError> {
185 if !self.initialized {
186 return Err(SandboxError::NotInitialized(
187 "Sandbox must be initialized before resolving paths".to_string(),
188 ));
189 }
190
191 let requested = Path::new(path);
192
193 let resolved = if requested.is_absolute() {
195 match requested.canonicalize() {
197 Ok(p) => p,
198 Err(_) => {
199 let components: Vec<_> = requested.components().collect();
201 let mut p = PathBuf::new();
202 for c in components {
203 p.push(c);
204 }
205 p
206 }
207 }
208 } else {
209 self.workdir.join(requested)
211 };
212
213 if !resolved.starts_with(&self.workdir) {
215 let temp_dirs = [
217 std::env::temp_dir(),
218 PathBuf::from("/tmp"),
219 PathBuf::from("/var/tmp"),
220 ];
221 let in_temp = temp_dirs.iter().any(|d| resolved.starts_with(d));
222
223 if !in_temp {
224 return Err(SandboxError::PathOutsideSandbox(
225 resolved.to_string_lossy().to_string(),
226 ));
227 }
228 }
229
230 Ok(resolved)
231 }
232
233 pub fn check_read_path(&self, path: &str) -> Result<PathBuf, SandboxError> {
235 let resolved = self.resolve_path(path)?;
236
237 if !resolved.exists() {
239 return Err(SandboxError::Io(std::io::Error::new(
240 std::io::ErrorKind::NotFound,
241 format!("Path does not exist: {}", resolved.display()),
242 )));
243 }
244
245 Ok(resolved)
246 }
247
248 pub fn check_write_path(&self, path: &str) -> Result<PathBuf, SandboxError> {
250 let resolved = self.resolve_path(path)?;
251
252 Ok(resolved)
256 }
257
258 pub fn check_network(&self) -> Result<(), SandboxError> {
260 if !self.config.allow_network {
261 return Err(SandboxError::NetworkDenied);
262 }
263 Ok(())
264 }
265
266 pub fn filtered_env(&self) -> Vec<(String, String)> {
268 std::env::vars()
269 .filter(|(key, _)| {
270 self.config
271 .allowed_env_prefixes
272 .iter()
273 .any(|prefix| key.starts_with(prefix))
274 })
275 .collect()
276 }
277
278 pub async fn cleanup(&mut self) -> Result<(), SandboxError> {
280 if self.initialized && self.workdir.exists() {
281 tokio::fs::remove_dir_all(&self.workdir).await?;
282 info!(workdir = %self.workdir.display(), "Sandbox cleaned up");
283 }
284 self.initialized = false;
285 Ok(())
286 }
287
288 pub async fn create_temp_file(
290 &self,
291 prefix: &str,
292 suffix: &str,
293 ) -> Result<PathBuf, SandboxError> {
294 if !self.initialized {
295 return Err(SandboxError::NotInitialized(
296 "Sandbox must be initialized before creating temp files".to_string(),
297 ));
298 }
299
300 let uuid = uuid::Uuid::new_v4();
302 let filename = format!("{}_{}{}", prefix, uuid, suffix);
303 let path = self.workdir.join(&filename);
304
305 tokio::fs::write(&path, "").await?;
307
308 Ok(path)
309 }
310}
311
312impl Drop for Sandbox {
313 fn drop(&mut self) {
314 if self.initialized && self.workdir.exists() {
315 let _ = std::fs::remove_dir_all(&self.workdir);
317 }
318 }
319}
320
321#[cfg(test)]
324mod tests {
325 use super::*;
326
327 #[tokio::test]
328 async fn test_sandbox_init() {
329 let dir =
330 std::env::temp_dir().join(format!("ravenclaws_sandbox_init_{}", std::process::id()));
331 let config = SandboxConfig {
332 workdir: dir.to_string_lossy().to_string(),
333 ..SandboxConfig::default()
334 };
335 let mut sandbox = Sandbox::new(config);
336 assert!(!sandbox.is_initialized());
337
338 sandbox.init().await.unwrap();
339 assert!(sandbox.is_initialized());
340 assert!(sandbox.workdir().exists());
341
342 sandbox.cleanup().await.unwrap();
343 assert!(!sandbox.is_initialized());
344 }
345
346 #[tokio::test]
347 async fn test_sandbox_resolve_relative_path() {
348 let dir =
349 std::env::temp_dir().join(format!("ravenclaws_sandbox_rel_{}", std::process::id()));
350 let config = SandboxConfig {
351 workdir: dir.to_string_lossy().to_string(),
352 create_workdir: true,
353 ..SandboxConfig::default()
354 };
355 let mut sandbox = Sandbox::new(config);
356 sandbox.init().await.unwrap();
357
358 let resolved = sandbox.resolve_path("test.txt").unwrap();
359 assert!(resolved.starts_with(&dir));
360 assert!(resolved.ends_with("test.txt"));
361
362 sandbox.cleanup().await.unwrap();
363 }
364
365 #[tokio::test]
366 async fn test_sandbox_resolve_absolute_path_in_sandbox() {
367 let dir =
368 std::env::temp_dir().join(format!("ravenclaws_sandbox_abs_{}", std::process::id()));
369 let config = SandboxConfig {
370 workdir: dir.to_string_lossy().to_string(),
371 create_workdir: true,
372 ..SandboxConfig::default()
373 };
374 let mut sandbox = Sandbox::new(config);
375 sandbox.init().await.unwrap();
376
377 let test_path = dir.join("subdir").join("file.txt");
378 let resolved = sandbox
379 .resolve_path(test_path.to_string_lossy().as_ref())
380 .unwrap();
381 assert!(resolved.starts_with(&dir));
382
383 sandbox.cleanup().await.unwrap();
384 }
385
386 #[tokio::test]
387 async fn test_sandbox_resolve_path_outside() {
388 let dir =
389 std::env::temp_dir().join(format!("ravenclaws_sandbox_outside_{}", std::process::id()));
390 let config = SandboxConfig {
391 workdir: dir.to_string_lossy().to_string(),
392 create_workdir: true,
393 ..SandboxConfig::default()
394 };
395 let mut sandbox = Sandbox::new(config);
396 sandbox.init().await.unwrap();
397
398 let result = sandbox.resolve_path("/etc/passwd");
400 if let Err(e) = result {
403 assert!(matches!(e, SandboxError::PathOutsideSandbox(_)));
404 }
405
406 sandbox.cleanup().await.unwrap();
407 }
408
409 #[tokio::test]
410 async fn test_sandbox_not_initialized() {
411 let config = SandboxConfig::default();
412 let sandbox = Sandbox::new(config);
413
414 let result = sandbox.resolve_path("test.txt");
415 assert!(matches!(
416 result.unwrap_err(),
417 SandboxError::NotInitialized(_)
418 ));
419 }
420
421 #[tokio::test]
422 async fn test_sandbox_check_read_path_not_found() {
423 let dir =
424 std::env::temp_dir().join(format!("ravenclaws_sandbox_read_{}", std::process::id()));
425 let config = SandboxConfig {
426 workdir: dir.to_string_lossy().to_string(),
427 create_workdir: true,
428 ..SandboxConfig::default()
429 };
430 let mut sandbox = Sandbox::new(config);
431 sandbox.init().await.unwrap();
432
433 let result = sandbox.check_read_path("nonexistent_file.txt");
434 assert!(result.is_err());
435
436 sandbox.cleanup().await.unwrap();
437 }
438
439 #[tokio::test]
440 async fn test_sandbox_check_network_allowed() {
441 let config = SandboxConfig {
442 allow_network: true,
443 ..SandboxConfig::default()
444 };
445 let sandbox = Sandbox::new(config);
446 assert!(sandbox.check_network().is_ok());
447 }
448
449 #[tokio::test]
450 async fn test_sandbox_check_network_denied() {
451 let config = SandboxConfig {
452 allow_network: false,
453 ..SandboxConfig::default()
454 };
455 let sandbox = Sandbox::new(config);
456 let result = sandbox.check_network();
457 assert!(matches!(result.unwrap_err(), SandboxError::NetworkDenied));
458 }
459
460 #[tokio::test]
461 async fn test_sandbox_filtered_env() {
462 let config = SandboxConfig::default();
463 let sandbox = Sandbox::new(config);
464 let env = sandbox.filtered_env();
465
466 assert!(env.iter().any(|(k, _)| k == "PATH"));
468
469 assert!(!env.iter().any(|(k, _)| k == "AWS_SECRET_ACCESS_KEY"));
471 }
472
473 #[tokio::test]
474 async fn test_sandbox_create_temp_file() {
475 let dir =
476 std::env::temp_dir().join(format!("ravenclaws_sandbox_temp_{}", std::process::id()));
477 let config = SandboxConfig {
478 workdir: dir.to_string_lossy().to_string(),
479 create_workdir: true,
480 ..SandboxConfig::default()
481 };
482 let mut sandbox = Sandbox::new(config);
483 sandbox.init().await.unwrap();
484
485 let path = sandbox.create_temp_file("test", ".txt").await.unwrap();
486 assert!(path.exists());
487 assert!(path.starts_with(&dir));
488
489 tokio::fs::remove_file(&path).await.unwrap();
491 sandbox.cleanup().await.unwrap();
492 }
493
494 #[tokio::test]
495 async fn test_sandbox_create_temp_file_not_initialized() {
496 let config = SandboxConfig::default();
497 let sandbox = Sandbox::new(config);
498
499 let result = sandbox.create_temp_file("test", ".txt").await;
500 assert!(matches!(
501 result.unwrap_err(),
502 SandboxError::NotInitialized(_)
503 ));
504 }
505
506 #[test]
507 fn test_sandbox_config_default() {
508 let config = SandboxConfig::default();
509 assert_eq!(config.workdir, "/tmp/ravenclaws-sandbox");
510 assert_eq!(config.timeout_secs, 30);
511 assert_eq!(config.max_output_bytes, 65536);
512 assert!(!config.allow_network);
513 assert!(config.create_workdir);
514 }
515
516 #[test]
517 fn test_sandbox_error_path_outside() {
518 let err = SandboxError::PathOutsideSandbox("/etc".to_string());
519 assert_eq!(format!("{}", err), "Path '/etc' is outside the sandbox");
520 }
521
522 #[test]
523 fn test_sandbox_error_not_initialized() {
524 let err = SandboxError::NotInitialized("not ready".to_string());
525 assert_eq!(format!("{}", err), "Sandbox not initialized: not ready");
526 }
527
528 #[test]
529 fn test_sandbox_error_resource_limit() {
530 let err = SandboxError::ResourceLimit("too big".to_string());
531 assert_eq!(format!("{}", err), "Resource limit exceeded: too big");
532 }
533
534 #[test]
535 fn test_sandbox_error_network_denied() {
536 let err = SandboxError::NetworkDenied;
537 assert_eq!(format!("{}", err), "Network access denied by sandbox");
538 }
539
540 #[test]
541 fn test_sandbox_drop_cleanup() {
542 let dir = std::env::temp_dir().join(format!(
543 "ravenclaws_sandbox_drop_test_{}",
544 std::process::id()
545 ));
546 let config = SandboxConfig {
547 workdir: dir.to_string_lossy().to_string(),
548 create_workdir: true,
549 ..SandboxConfig::default()
550 };
551
552 {
554 let mut sandbox = Sandbox::new(config);
555 let rt = tokio::runtime::Runtime::new().unwrap();
557 rt.block_on(sandbox.init()).unwrap();
558 assert!(dir.exists());
559 }
561
562 }
566
567 #[tokio::test]
568 async fn test_sandbox_check_write_path() {
569 let dir =
570 std::env::temp_dir().join(format!("ravenclaws_sandbox_write_{}", std::process::id()));
571 let config = SandboxConfig {
572 workdir: dir.to_string_lossy().to_string(),
573 create_workdir: true,
574 ..SandboxConfig::default()
575 };
576 let mut sandbox = Sandbox::new(config);
577 sandbox.init().await.unwrap();
578
579 let result = sandbox.check_write_path("new_file.txt");
580 assert!(result.is_ok());
581
582 sandbox.cleanup().await.unwrap();
583 }
584
585 #[test]
586 fn test_sandbox_config_serialization() {
587 let config = SandboxConfig::default();
588 let json = serde_json::to_string(&config).unwrap();
589 assert!(json.contains("/tmp/ravenclaws-sandbox"));
590 assert!(json.contains("30"));
591 }
592
593 #[test]
594 fn test_sandbox_config_deserialization() {
595 let json = r#"{
596 "workdir": "/custom/sandbox",
597 "timeout_secs": 60,
598 "allow_network": true
599 }"#;
600 let config: SandboxConfig = serde_json::from_str(json).unwrap();
601 assert_eq!(config.workdir, "/custom/sandbox");
602 assert_eq!(config.timeout_secs, 60);
603 assert!(config.allow_network);
604 }
605}