Skip to main content

oximedia_plugin/
sandbox.rs

1//! Plugin sandboxing: permission enforcement, resource limits, and timeout checking.
2//!
3//! This module provides a pure-Rust, no-OS-syscall sandbox model.  Policy is
4//! enforced by checking bitmask flags, path allow-lists, and atomic counters
5//! before each resource acquisition.  Actual OS-level isolation (e.g. seccomp,
6//! namespaces) is outside the scope of this crate and should be layered on top
7//! if required.
8//!
9//! # Permission Flags
10//!
11//! | Constant              | Value | Meaning                            |
12//! |-----------------------|-------|------------------------------------|
13//! | `PERM_NETWORK`        | 0x01  | Access external network             |
14//! | `PERM_FILESYSTEM`     | 0x02  | Read/write the local filesystem     |
15//! | `PERM_GPU`            | 0x04  | Submit work to a GPU                |
16//! | `PERM_AUDIO`          | 0x08  | Access audio hardware               |
17//! | `PERM_VIDEO`          | 0x10  | Access video capture hardware       |
18//! | `PERM_MEMORY_LARGE`   | 0x20  | Allocate > `max_memory_mb` of RAM   |
19//!
20//! # Fine-Grained Filesystem Restrictions
21//!
22//! When `PERM_FILESYSTEM` is granted, the plugin may further be constrained to
23//! a list of explicitly allowed paths.  Access to any path not in the allow-list
24//! is denied even if `PERM_FILESYSTEM` is set.  If the allow-list is empty,
25//! the entire filesystem is permitted (subject to the OS).
26//!
27//! # Resource Usage Tracking
28//!
29//! [`SandboxContext`] records both memory allocation and simulated CPU-time
30//! via atomic counters.  Callers report CPU work in nanoseconds using
31//! [`SandboxContext::charge_cpu_ns`]; the context compares the accumulated
32//! total against `max_cpu_ns` and returns [`SandboxError::CpuExceeded`] when
33//! the limit is breached.
34
35use std::path::{Path, PathBuf};
36use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
37use std::time::Instant;
38
39// ── Permission flags ──────────────────────────────────────────────────────────
40
41/// Permission to open network connections.
42pub const PERM_NETWORK: u32 = 0x01;
43/// Permission to read or write the local filesystem.
44pub const PERM_FILESYSTEM: u32 = 0x02;
45/// Permission to use a GPU.
46pub const PERM_GPU: u32 = 0x04;
47/// Permission to access audio hardware.
48pub const PERM_AUDIO: u32 = 0x08;
49/// Permission to access video capture hardware.
50pub const PERM_VIDEO: u32 = 0x10;
51/// Permission to allocate large amounts of memory (above `max_memory_mb`).
52pub const PERM_MEMORY_LARGE: u32 = 0x20;
53
54// ── PermissionSet ─────────────────────────────────────────────────────────────
55
56/// A bitmask-based set of granted permissions, optionally combined with
57/// fine-grained filesystem path restrictions.
58///
59/// Operations return `Self` by value for builder-pattern chaining.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct PermissionSet {
62    bits: u32,
63    /// Allow-list of filesystem paths the plugin may access.
64    ///
65    /// When non-empty and `PERM_FILESYSTEM` is set, only these paths (and
66    /// their descendants) are accessible.  An empty list means "all paths
67    /// permitted" (legacy behaviour).
68    allowed_paths: Vec<PathBuf>,
69}
70
71impl PermissionSet {
72    /// Create a set with no permissions granted.
73    pub fn new() -> Self {
74        Self {
75            bits: 0,
76            allowed_paths: Vec::new(),
77        }
78    }
79
80    /// Create a set with all known permissions granted (no path restrictions).
81    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    /// Grant the given permission flag(s), returning the updated set.
94    pub fn grant(self, flag: u32) -> Self {
95        Self {
96            bits: self.bits | flag,
97            allowed_paths: self.allowed_paths,
98        }
99    }
100
101    /// Revoke the given permission flag(s), returning the updated set.
102    pub fn revoke(self, flag: u32) -> Self {
103        Self {
104            bits: self.bits & !flag,
105            allowed_paths: self.allowed_paths,
106        }
107    }
108
109    /// Test whether the given permission flag(s) are all granted.
110    pub fn has(&self, flag: u32) -> bool {
111        self.bits & flag == flag
112    }
113
114    /// Return the raw bitmask.
115    pub fn bits(&self) -> u32 {
116        self.bits
117    }
118
119    /// Add a path to the filesystem allow-list.
120    ///
121    /// When the allow-list is non-empty, only paths that are equal to or
122    /// are descendants of an allowed path may be accessed (requires
123    /// `PERM_FILESYSTEM` to be set as well).
124    ///
125    /// The path is stored as-is (no canonicalisation is performed here;
126    /// canonicalise before calling if required).
127    #[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    /// Remove a path from the filesystem allow-list.
137    #[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    /// Return the filesystem path allow-list.
144    pub fn allowed_paths(&self) -> &[PathBuf] {
145        &self.allowed_paths
146    }
147
148    /// Check whether `path` is permitted by the current allow-list rules.
149    ///
150    /// Returns `true` if:
151    /// - `PERM_FILESYSTEM` is not set → always false (filesystem denied), or
152    /// - `PERM_FILESYSTEM` is set AND the allow-list is empty → always true, or
153    /// - `PERM_FILESYSTEM` is set AND `path` starts with any allowed path entry.
154    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// ── SandboxConfig ─────────────────────────────────────────────────────────────
174
175/// Static configuration for a plugin sandbox.
176#[derive(Debug, Clone)]
177pub struct SandboxConfig {
178    /// Which permissions the plugin is allowed to exercise.
179    pub permissions: PermissionSet,
180    /// Maximum RSS memory the plugin may consume (MiB).
181    pub max_memory_mb: usize,
182    /// Maximum CPU time as a percentage of one core (0–100).
183    pub max_cpu_percent: u8,
184    /// Wall-clock timeout for a single plugin operation (milliseconds).
185    pub timeout_ms: u64,
186    /// Maximum cumulative simulated CPU time (nanoseconds, 0 = unlimited).
187    ///
188    /// When non-zero, calls to [`SandboxContext::charge_cpu_ns`] will fail
189    /// once this budget is exhausted.
190    pub max_cpu_ns: u64,
191}
192
193impl Default for SandboxConfig {
194    /// Restrictive defaults: no permissions, 256 MiB, 50 % CPU, 5-second timeout.
195    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, // unlimited by default
202        }
203    }
204}
205
206impl SandboxConfig {
207    /// Create a fully permissive configuration (useful for testing).
208    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, // unlimited
215        }
216    }
217}
218
219// ── SandboxError ──────────────────────────────────────────────────────────────
220
221/// Errors that arise when a plugin violates its sandbox policy.
222#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
223pub enum SandboxError {
224    /// The plugin attempted to exercise a permission it does not hold.
225    #[error("permission denied: requested 0x{requested:02X}, available 0x{available:02X}")]
226    PermissionDenied {
227        /// The flag(s) requested.
228        requested: u32,
229        /// The flags currently granted.
230        available: u32,
231    },
232
233    /// The plugin attempted to access a filesystem path not in its allow-list.
234    #[error("filesystem path denied: {path}")]
235    PathDenied {
236        /// The path the plugin tried to access.
237        path: String,
238    },
239
240    /// The plugin attempted to allocate more memory than its limit allows.
241    #[error("memory limit exceeded: used {used} bytes, limit {limit} bytes")]
242    MemoryExceeded {
243        /// Current allocation (bytes).
244        used: usize,
245        /// Configured limit (bytes).
246        limit: usize,
247    },
248
249    /// The plugin exceeded its wall-clock timeout.
250    #[error("timeout: elapsed {elapsed_ms} ms")]
251    Timeout {
252        /// Elapsed milliseconds since the context was created.
253        elapsed_ms: u64,
254    },
255
256    /// The plugin exceeded its CPU quota.
257    #[error("CPU quota exceeded")]
258    CpuExceeded,
259}
260
261// ── ResourceSnapshot ──────────────────────────────────────────────────────────
262
263/// A point-in-time snapshot of a plugin's resource usage.
264#[derive(Debug, Clone, Copy)]
265pub struct ResourceSnapshot {
266    /// Memory currently allocated by the plugin (bytes).
267    pub memory_bytes: usize,
268    /// Cumulative simulated CPU time charged to this context (nanoseconds).
269    pub cpu_ns: u64,
270    /// Wall-clock time elapsed since context creation (milliseconds).
271    pub elapsed_ms: u64,
272}
273
274// ── SandboxContext ────────────────────────────────────────────────────────────
275
276/// A live sandbox context that enforces resource policy checks.
277///
278/// Each plugin execution should be associated with a `SandboxContext`.
279/// The context tracks memory consumption via an atomic counter and records
280/// its start time for timeout enforcement.
281///
282/// # Resource Tracking
283///
284/// - Memory is tracked via [`check_memory`](Self::check_memory) /
285///   [`release_memory`](Self::release_memory).
286/// - CPU time can be charged via [`charge_cpu_ns`](Self::charge_cpu_ns).
287/// - A live snapshot is available via [`resource_snapshot`](Self::resource_snapshot).
288pub struct SandboxContext {
289    /// Policy governing this context.
290    pub config: SandboxConfig,
291    /// Atomically tracked memory usage (bytes).
292    used_memory: AtomicUsize,
293    /// Accumulated simulated CPU time (nanoseconds).
294    used_cpu_ns: AtomicU64,
295    /// Moment this context was created (used for timeout checks).
296    start_time: Instant,
297}
298
299impl SandboxContext {
300    /// Create a new context from a `SandboxConfig`.
301    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    /// Verify that the given permission flag(s) are granted.
311    ///
312    /// # Errors
313    /// Returns [`SandboxError::PermissionDenied`] if any bit in `flag` is not granted.
314    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    /// Verify that the plugin is allowed to access the given filesystem `path`.
326    ///
327    /// Checks both the `PERM_FILESYSTEM` bit and the path allow-list.
328    ///
329    /// # Errors
330    /// Returns [`SandboxError::PermissionDenied`] if `PERM_FILESYSTEM` is not set,
331    /// or [`SandboxError::PathDenied`] if the path is not in the allow-list.
332    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    /// Attempt to record a memory allocation of `requested` bytes.
348    ///
349    /// Atomically increments the used-memory counter; if the result exceeds
350    /// the configured limit the increment is rolled back and an error is returned.
351    ///
352    /// # Errors
353    /// Returns [`SandboxError::MemoryExceeded`] if the limit would be breached.
354    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            // Roll back.
360            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    /// Release memory that was previously recorded with `check_memory`.
371    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    /// Charge `ns` nanoseconds of simulated CPU time to this context.
379    ///
380    /// If `max_cpu_ns` in the configuration is non-zero and the accumulated
381    /// total would exceed it, the charge is rolled back and
382    /// [`SandboxError::CpuExceeded`] is returned.
383    ///
384    /// # Errors
385    /// Returns [`SandboxError::CpuExceeded`] if the CPU budget is exhausted.
386    pub fn charge_cpu_ns(&self, ns: u64) -> Result<(), SandboxError> {
387        let limit = self.config.max_cpu_ns;
388        if limit == 0 {
389            // Unlimited — just record the charge.
390            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            // Roll back.
396            self.used_cpu_ns.fetch_sub(ns, Ordering::Relaxed);
397            Err(SandboxError::CpuExceeded)
398        } else {
399            Ok(())
400        }
401    }
402
403    /// Check whether the configured wall-clock timeout has been exceeded.
404    ///
405    /// # Errors
406    /// Returns [`SandboxError::Timeout`] if the elapsed time exceeds `timeout_ms`.
407    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    /// Return the current used-memory count (bytes).
418    pub fn used_memory_bytes(&self) -> usize {
419        self.used_memory.load(Ordering::Relaxed)
420    }
421
422    /// Return the accumulated simulated CPU time (nanoseconds).
423    pub fn used_cpu_ns(&self) -> u64 {
424        self.used_cpu_ns.load(Ordering::Relaxed)
425    }
426
427    /// Capture a point-in-time resource usage snapshot.
428    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
437// ── PluginSandbox ─────────────────────────────────────────────────────────────
438
439/// Wraps plugin execution with policy enforcement.
440///
441/// `PluginSandbox` owns a `SandboxContext` and exposes helpers that callers
442/// should invoke before performing resource-consuming operations within the
443/// plugin's execution boundary.
444pub struct PluginSandbox {
445    ctx: SandboxContext,
446}
447
448impl PluginSandbox {
449    /// Create a new sandbox from a `SandboxConfig`.
450    pub fn new(config: SandboxConfig) -> Self {
451        Self {
452            ctx: SandboxContext::new(config),
453        }
454    }
455
456    /// Obtain a reference to the inner context.
457    pub fn context(&self) -> &SandboxContext {
458        &self.ctx
459    }
460
461    /// Run `f` inside the sandbox, checking the timeout on entry.
462    ///
463    /// # Errors
464    /// Returns [`SandboxError::Timeout`] if the wall-clock limit is exceeded before
465    /// `f` is even called.  Errors from `f` are propagated unchanged.
466    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// ── Tests ─────────────────────────────────────────────────────────────────────
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480    use std::time::Duration;
481
482    // 1. PermissionSet::new has no permissions
483    #[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    // 2. with_all has all permissions
491    #[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    // 3. grant adds a permission
503    #[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    // 4. revoke removes a permission
511    #[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    // 5. check_permission success
519    #[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    // 6. check_permission denied
530    #[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    // 7. check_memory within limit
542    #[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()); // 512 KiB < 1 MiB
550    }
551
552    // 8. check_memory over limit
553    #[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    // 9. used_memory accumulates
569    #[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    // 10. release_memory decrements
582    #[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    // 11. check_timeout within limit
595    #[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    // 12. check_timeout exceeded (simulate with zero timeout)
606    #[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        // Sleep 1 ms to ensure elapsed > 0.
614        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    // 13. PluginSandbox::run propagates fn result
624    #[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    // 14. PluginSandbox::run denied permission propagates error
635    #[test]
636    fn test_plugin_sandbox_run_permission_denied() {
637        let sb = PluginSandbox::new(SandboxConfig::default()); // no perms
638        let result = sb.run(|ctx| ctx.check_permission(PERM_GPU).map(|_| ()));
639        assert!(matches!(result, Err(SandboxError::PermissionDenied { .. })));
640    }
641
642    // 15. SandboxError display messages
643    #[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    // 16. PermissionSet default equals new()
670    #[test]
671    fn test_perm_set_default() {
672        assert_eq!(PermissionSet::default(), PermissionSet::new());
673    }
674
675    // 17. Multiple grant/revoke chain
676    #[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    // 18. permissive config allows all permissions
687    #[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    // 19. memory rollback on failure keeps count unchanged
696    #[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        // This should fail (would exceed 1 MiB).
705        let _ = ctx.check_memory(768 * 1024);
706        // Used memory should still be only 512 KiB.
707        assert_eq!(ctx.used_memory_bytes(), 512 * 1024);
708    }
709
710    // 20. SandboxConfig default values
711    #[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    // 21. Path allow-list: no paths = all allowed (when PERM_FILESYSTEM set)
721    #[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    // 22. Path allow-list: restricts access to listed prefix
729    #[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    // 23. No PERM_FILESYSTEM → is_path_allowed returns false
741    #[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    // 24. check_path success
748    #[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    // 25. check_path denied (path not in allow-list)
761    #[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    // 26. check_path denied (no PERM_FILESYSTEM)
779    #[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    // 27. allow_path / deny_path builder
789    #[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    // 28. charge_cpu_ns: unlimited (max_cpu_ns = 0)
801    #[test]
802    fn test_cpu_charge_unlimited() {
803        let ctx = SandboxContext::new(SandboxConfig::default()); // max_cpu_ns = 0
804        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    // 29. charge_cpu_ns: limited, within budget
810    #[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    // 30. charge_cpu_ns: exceeded rolls back
821    #[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); // 600+500 > 1000
828        assert!(matches!(err, Err(SandboxError::CpuExceeded)));
829        // Rolled back — still 600
830        assert_eq!(ctx.used_cpu_ns(), 600);
831    }
832
833    // 31. resource_snapshot captures all fields
834    #[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        // elapsed_ms should be very small (< 1000 ms)
848        assert!(snap.elapsed_ms < 1000);
849    }
850}