Skip to main content

audio_plugin_bsd/
sandbox.rs

1//! Sandbox configuration: [`IsolationLevel`], [`SandboxConfig`], and platform
2//! availability checks.
3//!
4//! On FreeBSD the loader can confine each plugin to a Capsicum capability
5//! sandbox (`cap_enter`) or a full `pdfork` child process with per-process
6//! capability restrictions. On non-FreeBSD platforms the sandbox is a no-op:
7//! [`IsolationLevel::None`] is the only available option.
8//!
9//! # Usage
10//!
11//! ```rust
12//! use audio_plugin_bsd::sandbox::{IsolationLevel, SandboxConfig};
13//!
14//! let cfg = SandboxConfig::new()
15//!     .with_isolation_level(IsolationLevel::None)
16//!     .with_log_channel_capacity(64);
17//!
18//! assert!(cfg.isolation_level().is_available());
19//! ```
20
21/// The degree of sandbox isolation applied to a plugin process.
22///
23/// On non-FreeBSD platforms only [`None`](IsolationLevel::None) is available;
24/// requesting a stricter level yields [`PluginError::Sandbox`] at load time.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
26pub enum IsolationLevel {
27    /// No sandboxing — plugin code runs in the host process (unsafe with
28    /// untrusted plugins).
29    None,
30    /// FreeBSD Capsicum capability mode (`cap_enter`). The plugin retains
31    /// file descriptors inherited at load time but cannot open new ones.
32    #[cfg(target_os = "freebsd")]
33    Capsicum,
34    /// Full per-plugin process isolation via `pdfork` + Capsicum. Each plugin
35    /// runs in its own child process with a dedicated log channel.
36    #[cfg(target_os = "freebsd")]
37    CapsicumProcess,
38}
39
40impl IsolationLevel {
41    /// Return `true` when this isolation level is supported on the current
42    /// platform.
43    #[must_use]
44    pub fn is_available(self) -> bool {
45        match self {
46            Self::None => true,
47            #[cfg(target_os = "freebsd")]
48            Self::Capsicum => true,
49            #[cfg(target_os = "freebsd")]
50            Self::CapsicumProcess => true,
51        }
52    }
53
54    /// Human-readable name for logging / diagnostics.
55    #[must_use]
56    pub fn as_str(self) -> &'static str {
57        match self {
58            Self::None => "none",
59            #[cfg(target_os = "freebsd")]
60            Self::Capsicum => "capsicum",
61            #[cfg(target_os = "freebsd")]
62            Self::CapsicumProcess => "capsicum-process",
63        }
64    }
65}
66
67/// Builder for sandbox configuration.
68///
69/// All fields are `Copy`, so `SandboxConfig` is cheap to clone and pass by
70/// value. The default configuration uses [`IsolationLevel::None`] with a
71/// 16-element log channel.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
73pub struct SandboxConfig {
74    /// The isolation level to apply.
75    isolation_level: IsolationLevel,
76    /// Maximum number of log messages buffered between plugin and host when
77    /// running in a `pdfork` child process. A value of 0 disables buffering
78    /// (log messages are dropped immediately). Ignored when isolation is
79    /// [`None`](IsolationLevel::None).
80    log_channel_capacity: usize,
81}
82
83impl SandboxConfig {
84    /// Construct a default configuration: no isolation, 16-element log
85    /// channel.
86    #[must_use]
87    pub fn new() -> Self {
88        Self {
89            isolation_level: IsolationLevel::None,
90            log_channel_capacity: 16,
91        }
92    }
93
94    /// Set the isolation level.
95    #[must_use]
96    pub fn with_isolation_level(mut self, level: IsolationLevel) -> Self {
97        self.isolation_level = level;
98        self
99    }
100
101    /// Set the log channel capacity (number of buffered messages).
102    ///
103    /// A capacity of 0 means log messages are dropped. Values above
104    /// `u32::MAX` are clamped when encoding on the wire.
105    #[must_use]
106    pub fn with_log_channel_capacity(mut self, capacity: usize) -> Self {
107        self.log_channel_capacity = capacity;
108        self
109    }
110
111    /// The configured isolation level.
112    #[must_use]
113    pub fn isolation_level(self) -> IsolationLevel {
114        self.isolation_level
115    }
116
117    /// The configured log channel capacity.
118    #[must_use]
119    pub fn log_channel_capacity(self) -> usize {
120        self.log_channel_capacity
121    }
122
123    /// Return `true` when the configured isolation level is supported on the
124    /// current platform.
125    #[must_use]
126    pub fn is_platform_supported(self) -> bool {
127        self.isolation_level.is_available()
128    }
129}
130
131impl Default for SandboxConfig {
132    fn default() -> Self {
133        Self::new()
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    // --- IsolationLevel ---------------------------------------------------
142
143    #[test]
144    fn none_is_always_available() {
145        assert!(IsolationLevel::None.is_available());
146    }
147
148    #[test]
149    fn none_as_str() {
150        assert_eq!(IsolationLevel::None.as_str(), "none");
151    }
152
153    #[test]
154    fn none_is_copy_clone_eq_debug_hash() {
155        let a = IsolationLevel::None;
156        let b = a;
157        assert_eq!(a, b);
158        assert_eq!(format!("{a:?}"), "None");
159        let mut set = std::collections::HashSet::new();
160        set.insert(a);
161        assert!(set.contains(&b));
162    }
163
164    #[cfg(target_os = "freebsd")]
165    mod freebsd_tests {
166        use super::*;
167
168        #[test]
169        fn capsicum_is_available_on_freebsd() {
170            assert!(IsolationLevel::Capsicum.is_available());
171        }
172
173        #[test]
174        fn capsicum_process_is_available_on_freebsd() {
175            assert!(IsolationLevel::CapsicumProcess.is_available());
176        }
177
178        #[test]
179        fn capsicum_as_str() {
180            assert_eq!(IsolationLevel::Capsicum.as_str(), "capsicum");
181        }
182
183        #[test]
184        fn capsicum_process_as_str() {
185            assert_eq!(IsolationLevel::CapsicumProcess.as_str(), "capsicum-process");
186        }
187    }
188
189    // --- SandboxConfig ----------------------------------------------------
190
191    #[test]
192    fn default_has_none_and_capacity_16() {
193        let cfg = SandboxConfig::default();
194        assert_eq!(cfg.isolation_level(), IsolationLevel::None);
195        assert_eq!(cfg.log_channel_capacity(), 16);
196    }
197
198    #[test]
199    fn builder_sets_isolation_level() {
200        let cfg = SandboxConfig::new().with_isolation_level(IsolationLevel::None);
201        assert_eq!(cfg.isolation_level(), IsolationLevel::None);
202    }
203
204    #[test]
205    fn builder_sets_log_channel_capacity() {
206        let cfg = SandboxConfig::new().with_log_channel_capacity(64);
207        assert_eq!(cfg.log_channel_capacity(), 64);
208    }
209
210    #[test]
211    fn builder_chains() {
212        let cfg = SandboxConfig::new()
213            .with_isolation_level(IsolationLevel::None)
214            .with_log_channel_capacity(32);
215        assert_eq!(cfg.isolation_level(), IsolationLevel::None);
216        assert_eq!(cfg.log_channel_capacity(), 32);
217    }
218
219    #[test]
220    fn platform_supported_for_none() {
221        let cfg = SandboxConfig::new().with_isolation_level(IsolationLevel::None);
222        assert!(cfg.is_platform_supported());
223    }
224
225    #[test]
226    fn config_is_copy_clone_eq_debug_hash() {
227        let a = SandboxConfig::new();
228        let b = a;
229        assert_eq!(a, b);
230        assert!(format!("{a:?}").contains("SandboxConfig"));
231        let mut set = std::collections::HashSet::new();
232        set.insert(a);
233        assert!(set.contains(&b));
234    }
235
236    #[test]
237    fn zero_capacity_is_valid() {
238        let cfg = SandboxConfig::new().with_log_channel_capacity(0);
239        assert_eq!(cfg.log_channel_capacity(), 0);
240    }
241
242    #[test]
243    fn large_capacity_is_valid() {
244        let cfg = SandboxConfig::new().with_log_channel_capacity(usize::MAX);
245        assert_eq!(cfg.log_channel_capacity(), usize::MAX);
246    }
247}