1use std::path::{Path, PathBuf};
36use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
37use std::time::Instant;
38
39pub const PERM_NETWORK: u32 = 0x01;
43pub const PERM_FILESYSTEM: u32 = 0x02;
45pub const PERM_GPU: u32 = 0x04;
47pub const PERM_AUDIO: u32 = 0x08;
49pub const PERM_VIDEO: u32 = 0x10;
51pub const PERM_MEMORY_LARGE: u32 = 0x20;
53
54#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct PermissionSet {
62 bits: u32,
63 allowed_paths: Vec<PathBuf>,
69}
70
71impl PermissionSet {
72 pub fn new() -> Self {
74 Self {
75 bits: 0,
76 allowed_paths: Vec::new(),
77 }
78 }
79
80 pub fn with_all() -> Self {
82 Self {
83 bits: PERM_NETWORK
84 | PERM_FILESYSTEM
85 | PERM_GPU
86 | PERM_AUDIO
87 | PERM_VIDEO
88 | PERM_MEMORY_LARGE,
89 allowed_paths: Vec::new(),
90 }
91 }
92
93 pub fn grant(self, flag: u32) -> Self {
95 Self {
96 bits: self.bits | flag,
97 allowed_paths: self.allowed_paths,
98 }
99 }
100
101 pub fn revoke(self, flag: u32) -> Self {
103 Self {
104 bits: self.bits & !flag,
105 allowed_paths: self.allowed_paths,
106 }
107 }
108
109 pub fn has(&self, flag: u32) -> bool {
111 self.bits & flag == flag
112 }
113
114 pub fn bits(&self) -> u32 {
116 self.bits
117 }
118
119 #[must_use]
128 pub fn allow_path(mut self, path: impl Into<PathBuf>) -> Self {
129 let p = path.into();
130 if !self.allowed_paths.contains(&p) {
131 self.allowed_paths.push(p);
132 }
133 self
134 }
135
136 #[must_use]
138 pub fn deny_path(mut self, path: &Path) -> Self {
139 self.allowed_paths.retain(|p| p.as_path() != path);
140 self
141 }
142
143 pub fn allowed_paths(&self) -> &[PathBuf] {
145 &self.allowed_paths
146 }
147
148 pub fn is_path_allowed(&self, path: &Path) -> bool {
155 if !self.has(PERM_FILESYSTEM) {
156 return false;
157 }
158 if self.allowed_paths.is_empty() {
159 return true;
160 }
161 self.allowed_paths
162 .iter()
163 .any(|allowed| path.starts_with(allowed))
164 }
165}
166
167impl Default for PermissionSet {
168 fn default() -> Self {
169 Self::new()
170 }
171}
172
173#[derive(Debug, Clone)]
177pub struct SandboxConfig {
178 pub permissions: PermissionSet,
180 pub max_memory_mb: usize,
182 pub max_cpu_percent: u8,
184 pub timeout_ms: u64,
186 pub max_cpu_ns: u64,
191}
192
193impl Default for SandboxConfig {
194 fn default() -> Self {
196 Self {
197 permissions: PermissionSet::new(),
198 max_memory_mb: 256,
199 max_cpu_percent: 50,
200 timeout_ms: 5_000,
201 max_cpu_ns: 0, }
203 }
204}
205
206impl SandboxConfig {
207 pub fn permissive() -> Self {
209 Self {
210 permissions: PermissionSet::with_all(),
211 max_memory_mb: usize::MAX / (1024 * 1024),
212 max_cpu_percent: 100,
213 timeout_ms: u64::MAX,
214 max_cpu_ns: 0, }
216 }
217}
218
219#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
223pub enum SandboxError {
224 #[error("permission denied: requested 0x{requested:02X}, available 0x{available:02X}")]
226 PermissionDenied {
227 requested: u32,
229 available: u32,
231 },
232
233 #[error("filesystem path denied: {path}")]
235 PathDenied {
236 path: String,
238 },
239
240 #[error("memory limit exceeded: used {used} bytes, limit {limit} bytes")]
242 MemoryExceeded {
243 used: usize,
245 limit: usize,
247 },
248
249 #[error("timeout: elapsed {elapsed_ms} ms")]
251 Timeout {
252 elapsed_ms: u64,
254 },
255
256 #[error("CPU quota exceeded")]
258 CpuExceeded,
259}
260
261#[derive(Debug, Clone, Copy)]
265pub struct ResourceSnapshot {
266 pub memory_bytes: usize,
268 pub cpu_ns: u64,
270 pub elapsed_ms: u64,
272}
273
274pub struct SandboxContext {
289 pub config: SandboxConfig,
291 used_memory: AtomicUsize,
293 used_cpu_ns: AtomicU64,
295 start_time: Instant,
297}
298
299impl SandboxContext {
300 pub fn new(config: SandboxConfig) -> Self {
302 Self {
303 config,
304 used_memory: AtomicUsize::new(0),
305 used_cpu_ns: AtomicU64::new(0),
306 start_time: Instant::now(),
307 }
308 }
309
310 pub fn check_permission(&self, flag: u32) -> Result<(), SandboxError> {
315 if !self.config.permissions.has(flag) {
316 Err(SandboxError::PermissionDenied {
317 requested: flag,
318 available: self.config.permissions.bits(),
319 })
320 } else {
321 Ok(())
322 }
323 }
324
325 pub fn check_path(&self, path: &Path) -> Result<(), SandboxError> {
333 if !self.config.permissions.has(PERM_FILESYSTEM) {
334 return Err(SandboxError::PermissionDenied {
335 requested: PERM_FILESYSTEM,
336 available: self.config.permissions.bits(),
337 });
338 }
339 if !self.config.permissions.is_path_allowed(path) {
340 return Err(SandboxError::PathDenied {
341 path: path.to_string_lossy().into_owned(),
342 });
343 }
344 Ok(())
345 }
346
347 pub fn check_memory(&self, requested: usize) -> Result<(), SandboxError> {
355 let limit_bytes = self.config.max_memory_mb.saturating_mul(1024 * 1024);
356 let prev = self.used_memory.fetch_add(requested, Ordering::Relaxed);
357 let new_total = prev.saturating_add(requested);
358 if new_total > limit_bytes {
359 self.used_memory.fetch_sub(requested, Ordering::Relaxed);
361 Err(SandboxError::MemoryExceeded {
362 used: new_total,
363 limit: limit_bytes,
364 })
365 } else {
366 Ok(())
367 }
368 }
369
370 pub fn release_memory(&self, bytes: usize) {
372 self.used_memory.fetch_sub(
373 bytes.min(self.used_memory.load(Ordering::Relaxed)),
374 Ordering::Relaxed,
375 );
376 }
377
378 pub fn charge_cpu_ns(&self, ns: u64) -> Result<(), SandboxError> {
387 let limit = self.config.max_cpu_ns;
388 if limit == 0 {
389 self.used_cpu_ns.fetch_add(ns, Ordering::Relaxed);
391 return Ok(());
392 }
393 let prev = self.used_cpu_ns.fetch_add(ns, Ordering::Relaxed);
394 if prev.saturating_add(ns) > limit {
395 self.used_cpu_ns.fetch_sub(ns, Ordering::Relaxed);
397 Err(SandboxError::CpuExceeded)
398 } else {
399 Ok(())
400 }
401 }
402
403 pub fn check_timeout(&self) -> Result<(), SandboxError> {
408 let elapsed = self.start_time.elapsed();
409 let elapsed_ms = elapsed.as_millis() as u64;
410 if elapsed_ms > self.config.timeout_ms {
411 Err(SandboxError::Timeout { elapsed_ms })
412 } else {
413 Ok(())
414 }
415 }
416
417 pub fn used_memory_bytes(&self) -> usize {
419 self.used_memory.load(Ordering::Relaxed)
420 }
421
422 pub fn used_cpu_ns(&self) -> u64 {
424 self.used_cpu_ns.load(Ordering::Relaxed)
425 }
426
427 pub fn resource_snapshot(&self) -> ResourceSnapshot {
429 ResourceSnapshot {
430 memory_bytes: self.used_memory_bytes(),
431 cpu_ns: self.used_cpu_ns(),
432 elapsed_ms: self.start_time.elapsed().as_millis() as u64,
433 }
434 }
435}
436
437pub struct PluginSandbox {
445 ctx: SandboxContext,
446}
447
448impl PluginSandbox {
449 pub fn new(config: SandboxConfig) -> Self {
451 Self {
452 ctx: SandboxContext::new(config),
453 }
454 }
455
456 pub fn context(&self) -> &SandboxContext {
458 &self.ctx
459 }
460
461 pub fn run<F, T>(&self, f: F) -> Result<T, SandboxError>
467 where
468 F: FnOnce(&SandboxContext) -> Result<T, SandboxError>,
469 {
470 self.ctx.check_timeout()?;
471 f(&self.ctx)
472 }
473}
474
475#[cfg(test)]
478mod tests {
479 use super::*;
480 use std::time::Duration;
481
482 #[test]
484 fn test_perm_set_empty() {
485 let p = PermissionSet::new();
486 assert!(!p.has(PERM_NETWORK));
487 assert!(!p.has(PERM_FILESYSTEM));
488 }
489
490 #[test]
492 fn test_perm_set_all() {
493 let p = PermissionSet::with_all();
494 assert!(p.has(PERM_NETWORK));
495 assert!(p.has(PERM_FILESYSTEM));
496 assert!(p.has(PERM_GPU));
497 assert!(p.has(PERM_AUDIO));
498 assert!(p.has(PERM_VIDEO));
499 assert!(p.has(PERM_MEMORY_LARGE));
500 }
501
502 #[test]
504 fn test_perm_grant() {
505 let p = PermissionSet::new().grant(PERM_NETWORK);
506 assert!(p.has(PERM_NETWORK));
507 assert!(!p.has(PERM_FILESYSTEM));
508 }
509
510 #[test]
512 fn test_perm_revoke() {
513 let p = PermissionSet::with_all().revoke(PERM_NETWORK);
514 assert!(!p.has(PERM_NETWORK));
515 assert!(p.has(PERM_FILESYSTEM));
516 }
517
518 #[test]
520 fn test_check_permission_ok() {
521 let cfg = SandboxConfig {
522 permissions: PermissionSet::new().grant(PERM_FILESYSTEM),
523 ..SandboxConfig::default()
524 };
525 let ctx = SandboxContext::new(cfg);
526 assert!(ctx.check_permission(PERM_FILESYSTEM).is_ok());
527 }
528
529 #[test]
531 fn test_check_permission_denied() {
532 let ctx = SandboxContext::new(SandboxConfig::default());
533 match ctx.check_permission(PERM_NETWORK) {
534 Err(SandboxError::PermissionDenied { requested, .. }) => {
535 assert_eq!(requested, PERM_NETWORK);
536 }
537 other => panic!("expected PermissionDenied, got {other:?}"),
538 }
539 }
540
541 #[test]
543 fn test_check_memory_ok() {
544 let cfg = SandboxConfig {
545 max_memory_mb: 1,
546 ..SandboxConfig::default()
547 };
548 let ctx = SandboxContext::new(cfg);
549 assert!(ctx.check_memory(512 * 1024).is_ok()); }
551
552 #[test]
554 fn test_check_memory_exceeded() {
555 let cfg = SandboxConfig {
556 max_memory_mb: 1,
557 ..SandboxConfig::default()
558 };
559 let ctx = SandboxContext::new(cfg);
560 match ctx.check_memory(2 * 1024 * 1024) {
561 Err(SandboxError::MemoryExceeded { limit, .. }) => {
562 assert_eq!(limit, 1024 * 1024);
563 }
564 other => panic!("expected MemoryExceeded, got {other:?}"),
565 }
566 }
567
568 #[test]
570 fn test_used_memory_accumulates() {
571 let cfg = SandboxConfig {
572 max_memory_mb: 10,
573 ..SandboxConfig::default()
574 };
575 let ctx = SandboxContext::new(cfg);
576 ctx.check_memory(1024).expect("first");
577 ctx.check_memory(2048).expect("second");
578 assert_eq!(ctx.used_memory_bytes(), 3072);
579 }
580
581 #[test]
583 fn test_release_memory() {
584 let cfg = SandboxConfig {
585 max_memory_mb: 10,
586 ..SandboxConfig::default()
587 };
588 let ctx = SandboxContext::new(cfg);
589 ctx.check_memory(4096).expect("alloc");
590 ctx.release_memory(2048);
591 assert_eq!(ctx.used_memory_bytes(), 2048);
592 }
593
594 #[test]
596 fn test_check_timeout_ok() {
597 let cfg = SandboxConfig {
598 timeout_ms: 60_000,
599 ..SandboxConfig::default()
600 };
601 let ctx = SandboxContext::new(cfg);
602 assert!(ctx.check_timeout().is_ok());
603 }
604
605 #[test]
607 fn test_check_timeout_exceeded() {
608 let cfg = SandboxConfig {
609 timeout_ms: 0,
610 ..SandboxConfig::default()
611 };
612 let ctx = SandboxContext::new(cfg);
613 std::thread::sleep(Duration::from_millis(1));
615 match ctx.check_timeout() {
616 Err(SandboxError::Timeout { elapsed_ms }) => {
617 assert!(elapsed_ms >= 1);
618 }
619 other => panic!("expected Timeout, got {other:?}"),
620 }
621 }
622
623 #[test]
625 fn test_plugin_sandbox_run_ok() {
626 let sb = PluginSandbox::new(SandboxConfig::permissive());
627 let result = sb.run(|ctx| {
628 ctx.check_permission(PERM_NETWORK)?;
629 Ok(42u32)
630 });
631 assert_eq!(result.expect("run"), 42);
632 }
633
634 #[test]
636 fn test_plugin_sandbox_run_permission_denied() {
637 let sb = PluginSandbox::new(SandboxConfig::default()); let result = sb.run(|ctx| ctx.check_permission(PERM_GPU).map(|_| ()));
639 assert!(matches!(result, Err(SandboxError::PermissionDenied { .. })));
640 }
641
642 #[test]
644 fn test_sandbox_error_display() {
645 let e = SandboxError::PermissionDenied {
646 requested: 0x01,
647 available: 0x00,
648 };
649 assert!(e.to_string().contains("permission denied"));
650
651 let e2 = SandboxError::MemoryExceeded {
652 used: 100,
653 limit: 50,
654 };
655 assert!(e2.to_string().contains("memory"));
656
657 let e3 = SandboxError::Timeout { elapsed_ms: 6000 };
658 assert!(e3.to_string().contains("timeout"));
659
660 let e4 = SandboxError::CpuExceeded;
661 assert!(e4.to_string().contains("CPU"));
662
663 let e5 = SandboxError::PathDenied {
664 path: "/etc/passwd".to_string(),
665 };
666 assert!(e5.to_string().contains("/etc/passwd"));
667 }
668
669 #[test]
671 fn test_perm_set_default() {
672 assert_eq!(PermissionSet::default(), PermissionSet::new());
673 }
674
675 #[test]
677 fn test_perm_chain() {
678 let p = PermissionSet::new()
679 .grant(PERM_NETWORK)
680 .grant(PERM_FILESYSTEM)
681 .revoke(PERM_NETWORK);
682 assert!(!p.has(PERM_NETWORK));
683 assert!(p.has(PERM_FILESYSTEM));
684 }
685
686 #[test]
688 fn test_permissive_config() {
689 let ctx = SandboxContext::new(SandboxConfig::permissive());
690 assert!(ctx.check_permission(PERM_NETWORK).is_ok());
691 assert!(ctx.check_permission(PERM_GPU).is_ok());
692 assert!(ctx.check_permission(PERM_MEMORY_LARGE).is_ok());
693 }
694
695 #[test]
697 fn test_memory_rollback() {
698 let cfg = SandboxConfig {
699 max_memory_mb: 1,
700 ..SandboxConfig::default()
701 };
702 let ctx = SandboxContext::new(cfg);
703 ctx.check_memory(512 * 1024).expect("first 512 KiB");
704 let _ = ctx.check_memory(768 * 1024);
706 assert_eq!(ctx.used_memory_bytes(), 512 * 1024);
708 }
709
710 #[test]
712 fn test_default_config() {
713 let cfg = SandboxConfig::default();
714 assert_eq!(cfg.max_memory_mb, 256);
715 assert_eq!(cfg.max_cpu_percent, 50);
716 assert_eq!(cfg.timeout_ms, 5_000);
717 assert!(!cfg.permissions.has(PERM_NETWORK));
718 }
719
720 #[test]
722 fn test_path_empty_allowlist_permits_all() {
723 let perms = PermissionSet::new().grant(PERM_FILESYSTEM);
724 assert!(perms.is_path_allowed(Path::new("/any/path")));
725 assert!(perms.is_path_allowed(Path::new("/etc/hosts")));
726 }
727
728 #[test]
730 fn test_path_allowlist_restricts() {
731 let perms = PermissionSet::new()
732 .grant(PERM_FILESYSTEM)
733 .allow_path("/tmp/plugin-data");
734 assert!(perms.is_path_allowed(Path::new("/tmp/plugin-data/file.bin")));
735 assert!(perms.is_path_allowed(Path::new("/tmp/plugin-data")));
736 assert!(!perms.is_path_allowed(Path::new("/etc/passwd")));
737 assert!(!perms.is_path_allowed(Path::new("/tmp/other")));
738 }
739
740 #[test]
742 fn test_path_no_filesystem_perm() {
743 let perms = PermissionSet::new().allow_path("/tmp");
744 assert!(!perms.is_path_allowed(Path::new("/tmp/file")));
745 }
746
747 #[test]
749 fn test_check_path_ok() {
750 let cfg = SandboxConfig {
751 permissions: PermissionSet::new()
752 .grant(PERM_FILESYSTEM)
753 .allow_path("/tmp/plugin"),
754 ..SandboxConfig::default()
755 };
756 let ctx = SandboxContext::new(cfg);
757 assert!(ctx.check_path(Path::new("/tmp/plugin/data.bin")).is_ok());
758 }
759
760 #[test]
762 fn test_check_path_denied() {
763 let cfg = SandboxConfig {
764 permissions: PermissionSet::new()
765 .grant(PERM_FILESYSTEM)
766 .allow_path("/tmp/plugin"),
767 ..SandboxConfig::default()
768 };
769 let ctx = SandboxContext::new(cfg);
770 match ctx.check_path(Path::new("/etc/shadow")) {
771 Err(SandboxError::PathDenied { path }) => {
772 assert!(path.contains("/etc/shadow"));
773 }
774 other => panic!("expected PathDenied, got {other:?}"),
775 }
776 }
777
778 #[test]
780 fn test_check_path_no_fs_perm() {
781 let ctx = SandboxContext::new(SandboxConfig::default());
782 assert!(matches!(
783 ctx.check_path(Path::new("/tmp/any")),
784 Err(SandboxError::PermissionDenied { .. })
785 ));
786 }
787
788 #[test]
790 fn test_allow_deny_path_builder() {
791 let perms = PermissionSet::new()
792 .grant(PERM_FILESYSTEM)
793 .allow_path("/tmp/a")
794 .allow_path("/tmp/b")
795 .deny_path(Path::new("/tmp/a"));
796 assert!(!perms.is_path_allowed(Path::new("/tmp/a/file")));
797 assert!(perms.is_path_allowed(Path::new("/tmp/b/file")));
798 }
799
800 #[test]
802 fn test_cpu_charge_unlimited() {
803 let ctx = SandboxContext::new(SandboxConfig::default()); ctx.charge_cpu_ns(1_000_000).expect("unlimited");
805 ctx.charge_cpu_ns(9_999_999_999).expect("still unlimited");
806 assert_eq!(ctx.used_cpu_ns(), 10_000_999_999);
807 }
808
809 #[test]
811 fn test_cpu_charge_within_budget() {
812 let mut cfg = SandboxConfig::default();
813 cfg.max_cpu_ns = 1_000_000;
814 let ctx = SandboxContext::new(cfg);
815 ctx.charge_cpu_ns(500_000).expect("within");
816 ctx.charge_cpu_ns(400_000).expect("still within");
817 assert_eq!(ctx.used_cpu_ns(), 900_000);
818 }
819
820 #[test]
822 fn test_cpu_charge_exceeded() {
823 let mut cfg = SandboxConfig::default();
824 cfg.max_cpu_ns = 1_000;
825 let ctx = SandboxContext::new(cfg);
826 ctx.charge_cpu_ns(600).expect("first");
827 let err = ctx.charge_cpu_ns(500); assert!(matches!(err, Err(SandboxError::CpuExceeded)));
829 assert_eq!(ctx.used_cpu_ns(), 600);
831 }
832
833 #[test]
835 fn test_resource_snapshot() {
836 let cfg = SandboxConfig {
837 max_memory_mb: 10,
838 max_cpu_ns: 0,
839 ..SandboxConfig::default()
840 };
841 let ctx = SandboxContext::new(cfg);
842 ctx.check_memory(1024).expect("mem");
843 ctx.charge_cpu_ns(500_000).expect("cpu");
844 let snap = ctx.resource_snapshot();
845 assert_eq!(snap.memory_bytes, 1024);
846 assert_eq!(snap.cpu_ns, 500_000);
847 assert!(snap.elapsed_ms < 1000);
849 }
850}