Skip to main content

audio_plugin_bsd/
isolation.rs

1//! Per-plugin process isolation runtime.
2//!
3//! On FreeBSD each plugin can run inside its own `pdfork` child process
4//! confined to a Capsicum capability sandbox. The host (parent) and the plugin
5//! (child) communicate over a Unix-domain socketpair using the length-prefixed
6//! framing defined in this module on top of the [`crate::proto`] messages.
7//!
8//! # 0.1.0 scope
9//!
10//! The control loop — [`Request::Ping`], [`Request::GetMetadata`],
11//! [`Request::Instantiate`], [`Request::Process`], and EOF-driven shutdown —
12//! is fully implemented. The child `dlopen`s the plugin, verifies its ABI,
13//! copies out its metadata, restricts the IPC socket to `READ | WRITE`,
14//! enters capability mode (`cap_enter`, irreversible), then serves the control
15//! loop until the host closes the IPC socket.
16//!
17//! **Real-time audio-frame marshalling** (shared-memory transport between the
18//! host RT thread and the sandboxed child) is a documented follow-up: in
19//! 0.1.0 [`Request::Process`] is acknowledged with [`Response::Processed`]
20//! without running actual DSP inside the child.
21//!
22//! # Platform availability
23//!
24//! [`spawn_isolated`] and [`SandboxedProcess`] are defined on every target so
25//! the public API compiles everywhere, but the runtime only works on FreeBSD.
26//! On non-FreeBSD targets [`spawn_isolated`] always returns
27//! [`PluginError::Sandbox`] and no [`SandboxedProcess`] can ever be
28//! constructed.
29//!
30//! # Verification
31//!
32//! Because Capsicum and `pdfork` are FreeBSD-only, the FreeBSD path is
33//! verified by `cargo check --target x86_64-unknown-freebsd` (compilation)
34//! plus Layer 4 VM regression; it is never executed on a Linux host. The
35//! non-FreeBSD stub is unit-tested on Linux.
36
37/// Maximum number of bytes the host will allocate for a single
38/// length-prefixed IPC frame received from a sandboxed child.
39///
40/// This is a defensive ceiling against a malicious or buggy child that sends a
41/// forged 4-byte length header advertising an enormous payload (e.g. 4 GiB):
42/// without the cap, `recv_frame` would `vec![0u8; len]` and OOM the parent
43/// (the host trusts the child only as far as Capsicum enforces — a compromised
44/// child can still write arbitrary bytes on the IPC socket). 16 MiB is far
45/// above any 0.1.0 control message (`GetMetadata` / `Instantiate` / `Process`
46/// responses are a few hundred bytes), so a legitimate child never trips the
47/// limit.
48///
49/// The constant lives at module scope (not inside the FreeBSD `imp` block) so
50/// it can be unit-tested on every target; on non-FreeBSD builds it has no
51/// runtime consumer and is therefore marked `allow(dead_code)`.
52#[cfg_attr(not(target_os = "freebsd"), allow(dead_code))]
53pub(crate) const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024;
54
55// =========================================================================
56// FreeBSD: pdfork + Capsicum runtime
57// =========================================================================
58#[cfg(target_os = "freebsd")]
59mod imp {
60    use capsicum::{CapRights, FileRights, Right};
61    use libloading::Library;
62    use std::ffi::CString;
63    use std::io;
64    use std::os::fd::BorrowedFd;
65    use std::os::unix::io::RawFd;
66    use std::path::Path;
67
68    use crate::abi::{is_abi_compatible, AUDIO_PLUGIN_ABI_MAGIC, AUDIO_PLUGIN_ABI_VERSION};
69    use crate::error::{PluginError, Result};
70    use crate::metadata::PluginMetadata;
71    use crate::proto::{
72        decode_request, decode_response, encode_request, encode_response, PluginMetadataPayload,
73        Request, Response,
74    };
75    use crate::sandbox::SandboxConfig;
76    use crate::symbols::{
77        raw_to_metadata, AbiMagicFn, AbiVersionFn, MetadataFn, AUDIO_PLUGIN_ABI_MAGIC_SYMBOL,
78        AUDIO_PLUGIN_ABI_VERSION_SYMBOL, AUDIO_PLUGIN_METADATA_SYMBOL,
79    };
80
81    // NOTE: pdfork'd children never become zombies and need no `pdwait`/
82    // `pdwait4`/`waitpid`: the process descriptor's terminate-on-close
83    // semantics mean the kernel reaps the child when the descriptor is closed
84    // (see `pdfork::ChildHandle`'s `Drop`, which runs `close(child_pd)`). The
85    // child also self-terminates (`_exit`) on IPC EOF, so `shutdown`/`Drop`
86    // close the IPC socket then drop the `ChildHandle` to reap.
87    // (`libc` 0.2 lacks `pdwait4`, and the symbol itself was renamed to
88    // `pdwait(fd, status, options, wrusage, siginfo)` in FreeBSD 14+/15, with a
89    // different signature than the historical `pdwait4` — so a manual extern
90    // declaration would be fragile across versions. The close-reap model is
91    // both portable and the idiomatic pdfork usage.)
92
93    /// A plugin running inside its own `pdfork` child process, confined to a
94    /// Capsicum capability sandbox.
95    ///
96    /// The host talks to the child over the `ipc` socket. Drop (or
97    /// [`shutdown`][SandboxedProcess::shutdown]) closes the IPC socket and
98    /// reaps the child when the process descriptor is closed.
99    #[derive(Debug)]
100    pub struct SandboxedProcess {
101        /// Process-descriptor handle; `None` once shutdown has reaped the child.
102        handle: Option<pdfork::ChildHandle>,
103        /// Parent's end of the IPC socketpair; `-1` once closed.
104        ipc: RawFd,
105        /// Plugin id used in IPC requests (always 0 in 0.1.0).
106        plugin_id: u32,
107    }
108
109    /// Spawn an isolated plugin child process.
110    ///
111    /// # Steps
112    ///
113    /// 1. Validate `plugin_path` (must be an existing file).
114    /// 2. `open(plugin_path, O_RDONLY | O_CLOEXEC)` — confirms readability
115    ///    before forking and gives the parent an fd to pass to the child.
116    /// 3. `socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC)` for host↔plugin IPC.
117    /// 4. `pdfork()`. The child closes the parent's IPC end, `dlopen`s the
118    ///    plugin (before `cap_enter`, while the global namespace is still
119    ///    reachable), restricts its IPC socket to `READ | WRITE`, enters
120    ///    capability mode, and serves the control loop. The parent closes the
121    ///    child's IPC end and the plugin fd and returns the handle.
122    ///
123    /// # Errors
124    ///
125    /// - [`PluginError::InvalidPath`] — missing/unreadable file or NUL in path.
126    /// - [`PluginError::Sandbox`] — `socketpair` / `pdfork` failure.
127    /// - (Inside the child) ABI/metadata errors cause the child to `_exit(1)`
128    ///   before responding; the parent observes this as an IPC error on the
129    ///   first request.
130    ///
131    /// # Panics
132    ///
133    /// Never. All fallible operations are propagated via [`Result`].
134    pub fn spawn_isolated(plugin_path: &Path, _cfg: &SandboxConfig) -> Result<SandboxedProcess> {
135        // 1. Path validation.
136        if !plugin_path.is_file() {
137            return Err(PluginError::InvalidPath(format!(
138                "{}: not an existing file",
139                plugin_path.display()
140            )));
141        }
142        let path_cstr = CString::new(plugin_path.as_os_str().as_encoded_bytes()).map_err(|e| {
143            PluginError::InvalidPath(format!(
144                "{}: path contains a NUL byte: {e}",
145                plugin_path.display()
146            ))
147        })?;
148
149        // 2. Open the plugin .so read-only (validates readability; the fd is
150        //    inherited by the child and closed there after dlopen succeeds).
151        let plugin_fd = open_readonly(&path_cstr)?;
152
153        // 3. IPC socketpair.
154        let (parent_fd, child_fd) = socketpair_cloexec()?;
155
156        // 4. pdfork.
157        match pdfork::fork() {
158            pdfork::ForkResult::Child => {
159                // In the child: we no longer need the parent's plugin fd (the
160                // child dlopen's the path itself before cap_enter) nor the
161                // parent's IPC end.
162                close_fd(plugin_fd);
163                // Never returns.
164                child_main(plugin_path, child_fd, parent_fd);
165            }
166            pdfork::ForkResult::Parent(handle) => {
167                // Parent keeps only its IPC end.
168                close_fd(child_fd);
169                close_fd(plugin_fd);
170                Ok(SandboxedProcess {
171                    handle: Some(handle),
172                    ipc: parent_fd,
173                    plugin_id: 0,
174                })
175            }
176            pdfork::ForkResult::Fail => {
177                close_fd(parent_fd);
178                close_fd(child_fd);
179                close_fd(plugin_fd);
180                Err(PluginError::Sandbox(
181                    "pdfork failed: process descriptor unavailable".into(),
182                ))
183            }
184        }
185    }
186
187    impl SandboxedProcess {
188        /// The plugin id used in IPC requests (always 0 in 0.1.0).
189        #[must_use]
190        pub fn plugin_id(&self) -> u32 {
191            self.plugin_id
192        }
193
194        /// Request the plugin's static metadata over IPC.
195        ///
196        /// # Errors
197        ///
198        /// Returns [`PluginError::Sandbox`] on any IPC or protocol error, or
199        /// when the child reports an error / unexpected response.
200        pub fn request_metadata(&mut self) -> Result<PluginMetadataPayload> {
201            let req = encode_request(&Request::GetMetadata {
202                plugin_id: self.plugin_id,
203            });
204            send_frame(self.ipc, &req)
205                .map_err(|e| PluginError::Sandbox(format!("ipc write: {e}")))?;
206            let buf =
207                recv_frame(self.ipc).map_err(|e| PluginError::Sandbox(format!("ipc read: {e}")))?;
208            match decode_response(&buf)? {
209                Response::Metadata { payload, .. } => Ok(payload),
210                Response::Error { message, .. } => {
211                    Err(PluginError::Sandbox(format!("plugin: {message}")))
212                }
213                other => Err(PluginError::Sandbox(format!(
214                    "unexpected response to GetMetadata: {other:?}"
215                ))),
216            }
217        }
218
219        /// Shut the child down: close the IPC socket (the child's control loop
220        /// observes EOF and `_exit`s), then drop the process descriptor, which
221        /// reaps the child via pdfork's terminate-on-close semantics.
222        ///
223        /// Safe to call more than once (subsequent calls are no-ops). This
224        /// method does not block on the child: the kernel reaps it when the
225        /// descriptor closes, regardless of timing.
226        ///
227        /// # Errors
228        ///
229        /// Currently always returns `Ok`; the `Result` is kept for API parity
230        /// with the non-FreeBSD stub and future synchronous-reap variants.
231        pub fn shutdown(&mut self) -> Result<()> {
232            if self.ipc >= 0 {
233                close_fd(self.ipc);
234                self.ipc = -1;
235            }
236            // Dropping the ChildHandle closes the process descriptor; the
237            // kernel then reaps the child (pdfork processes do not zombie and
238            // need no pdwait/waitpid).
239            drop(self.handle.take());
240            Ok(())
241        }
242    }
243
244    impl Drop for SandboxedProcess {
245        fn drop(&mut self) {
246            // Best-effort cleanup if shutdown() was not called explicitly.
247            if self.ipc >= 0 {
248                close_fd(self.ipc);
249                self.ipc = -1;
250            }
251            // Dropping the ChildHandle closes the process descriptor; the
252            // kernel reaps the child via terminate-on-close.
253            drop(self.handle.take());
254        }
255    }
256
257    // ----- child-side entry point -----------------------------------------
258
259    /// Child process entry point. Runs the control loop and `_exit`s; never
260    /// returns.
261    fn child_main(plugin_path: &Path, child_fd: RawFd, parent_fd: RawFd) -> ! {
262        // Close the parent's IPC end; the child only uses `child_fd`.
263        close_fd(parent_fd);
264
265        // dlopen + ABI verify + metadata fetch BEFORE cap_enter: after entering
266        // capability mode the global filesystem namespace is unreachable, so
267        // path-based dlopen would fail. `_library` is intentionally kept alive
268        // for the whole control loop so the plugin's symbols stay mapped.
269        let Ok((_library, payload)) = load_plugin_payload(plugin_path) else {
270            child_exit(1);
271        };
272
273        // Restrict the IPC socket to read + write only.
274        if limit_ipc_rights(child_fd).is_err() {
275            child_exit(1);
276        }
277
278        // Enter capability mode (irreversible sandbox).
279        if capsicum::enter().is_err() {
280            child_exit(1);
281        }
282
283        child_loop(child_fd, &payload);
284        child_exit(0);
285    }
286
287    /// The control loop: read framed requests, dispatch, write framed
288    /// responses, until EOF or an unrecoverable error.
289    fn child_loop(child_fd: RawFd, payload: &PluginMetadataPayload) {
290        loop {
291            let Ok(buf) = recv_frame(child_fd) else {
292                return;
293            };
294            let Ok(req) = decode_request(&buf) else {
295                return;
296            };
297            let resp = dispatch_request(&req, payload);
298            if send_frame(child_fd, &encode_response(&resp)).is_err() {
299                return;
300            }
301        }
302    }
303
304    /// Map a [`Request`] to a [`Response`] (0.1.0 control surface).
305    fn dispatch_request(req: &Request, payload: &PluginMetadataPayload) -> Response {
306        match req {
307            Request::Ping => Response::Pong,
308            Request::GetMetadata { plugin_id } => Response::Metadata {
309                plugin_id: *plugin_id,
310                payload: payload.clone(),
311            },
312            Request::Instantiate { plugin_id } => Response::InstanceCreated {
313                plugin_id: *plugin_id,
314            },
315            // 0.1.0: acknowledge without running DSP (RT frame marshalling is
316            // a documented follow-up).
317            Request::Process { plugin_id, .. } => Response::Processed {
318                plugin_id: *plugin_id,
319            },
320        }
321    }
322
323    /// Terminate the child process immediately without running destructors or
324    /// `atexit` handlers, as required after `fork` for async-signal-safety.
325    fn child_exit(code: i32) -> ! {
326        // SAFETY: `_exit` is async-signal-safe and the correct way to terminate
327        // a forked child. It does not return.
328        unsafe {
329            libc::_exit(code);
330        }
331    }
332
333    // ----- plugin load (child side) ---------------------------------------
334
335    /// `dlopen` the plugin, verify its ABI, and copy out its metadata as a
336    /// [`PluginMetadataPayload`]. Returns the live `Library` alongside the
337    /// payload so the caller can keep it mapped.
338    fn load_plugin_payload(path: &Path) -> Result<(Library, PluginMetadataPayload)> {
339        let library = unsafe { Library::new(path) }
340            .map_err(|e| PluginError::LibraryLoad(format!("{}: {e}", path.display())))?;
341
342        // ABI magic.
343        let plugin_magic = unsafe {
344            let sym: libloading::Symbol<AbiMagicFn> = library
345                .get(AUDIO_PLUGIN_ABI_MAGIC_SYMBOL.as_bytes())
346                .map_err(|e| {
347                    PluginError::SymbolMissing(format!("{AUDIO_PLUGIN_ABI_MAGIC_SYMBOL}: {e}"))
348                })?;
349            sym()
350        };
351        if plugin_magic != AUDIO_PLUGIN_ABI_MAGIC {
352            return Err(PluginError::InvalidMetadata(format!(
353                "ABI magic mismatch: expected {AUDIO_PLUGIN_ABI_MAGIC:#010x}, got {plugin_magic:#010x}"
354            )));
355        }
356
357        // ABI version (major must match host).
358        let plugin_version = unsafe {
359            let sym: libloading::Symbol<AbiVersionFn> = library
360                .get(AUDIO_PLUGIN_ABI_VERSION_SYMBOL.as_bytes())
361                .map_err(|e| {
362                    PluginError::SymbolMissing(format!("{AUDIO_PLUGIN_ABI_VERSION_SYMBOL}: {e}"))
363                })?;
364            sym()
365        };
366        if !is_abi_compatible(AUDIO_PLUGIN_ABI_VERSION, plugin_version) {
367            return Err(PluginError::AbiVersionMismatch {
368                host: AUDIO_PLUGIN_ABI_VERSION,
369                plugin: plugin_version,
370            });
371        }
372
373        // Metadata.
374        let md: PluginMetadata = unsafe {
375            let sym: libloading::Symbol<MetadataFn> = library
376                .get(AUDIO_PLUGIN_METADATA_SYMBOL.as_bytes())
377                .map_err(|e| {
378                    PluginError::SymbolMissing(format!("{AUDIO_PLUGIN_METADATA_SYMBOL}: {e}"))
379                })?;
380            // SAFETY: `sym()` returns the plugin's 'static metadata pointer;
381            // the library (and thus the plugin's data segment) is alive because
382            // `library` is in scope and outlives this call.
383            raw_to_metadata(sym())?
384        };
385
386        let payload = PluginMetadataPayload {
387            name: md.name,
388            version: md.version,
389            description: md.description,
390            abi_version: md.abi_version,
391        };
392        Ok((library, payload))
393    }
394
395    // ----- Capsicum helpers -----------------------------------------------
396
397    /// Restrict `fd` to `READ | WRITE` capability rights.
398    fn limit_ipc_rights(fd: RawFd) -> io::Result<()> {
399        let mut rights = FileRights::new();
400        rights.allow(Right::Read);
401        rights.allow(Right::Write);
402        // SAFETY: `fd` is a valid, open file descriptor (the child's IPC socket
403        // end) inherited from the parent. `BorrowedFd::borrow_raw` requires the
404        // fd be valid for the duration of the borrow, which holds for this call.
405        let borrowed = unsafe { BorrowedFd::borrow_raw(fd) };
406        rights.limit(&borrowed)
407    }
408
409    // ----- raw fd / syscall helpers ---------------------------------------
410
411    /// Open `path` read-only with `O_CLOEXEC`.
412    fn open_readonly(path: &CString) -> Result<RawFd> {
413        // SAFETY: `path` is a valid NUL-terminated C string owned by the caller;
414        // `open` does not retain the pointer beyond the call.
415        let fd = unsafe { libc::open(path.as_ptr(), libc::O_RDONLY | libc::O_CLOEXEC) };
416        if fd < 0 {
417            Err(PluginError::InvalidPath(format!(
418                "{}: cannot open for reading: {}",
419                path.to_string_lossy(),
420                io::Error::last_os_error()
421            )))
422        } else {
423            Ok(fd)
424        }
425    }
426
427    /// Create a `SOCK_STREAM | SOCK_CLOEXEC` Unix-domain socketpair.
428    fn socketpair_cloexec() -> Result<(RawFd, RawFd)> {
429        let mut fds = [0_i32; 2];
430        // SAFETY: `fds` is a valid 2-element array; socketpair writes two fds
431        // or returns -1. The pointer is not retained.
432        let rc = unsafe {
433            libc::socketpair(
434                libc::AF_UNIX,
435                libc::SOCK_STREAM | libc::SOCK_CLOEXEC,
436                0,
437                fds.as_mut_ptr(),
438            )
439        };
440        if rc < 0 {
441            Err(PluginError::Sandbox(format!(
442                "socketpair failed: {}",
443                io::Error::last_os_error()
444            )))
445        } else {
446            Ok((fds[0], fds[1]))
447        }
448    }
449
450    /// Idempotent close: ignores `-1` and `EBADF`.
451    fn close_fd(fd: RawFd) {
452        if fd >= 0 {
453            // SAFETY: `fd` is either a valid open descriptor or already-closed
454            // (close on a bad fd sets EBADF, which we discard). `close` does not
455            // retain anything.
456            unsafe {
457                let _ = libc::close(fd);
458            }
459        }
460    }
461
462    // ----- length-prefixed IPC framing ------------------------------------
463
464    /// Write a 4-byte little-endian length header followed by `payload`.
465    fn send_frame(fd: RawFd, payload: &[u8]) -> io::Result<()> {
466        let len = u32::try_from(payload.len())
467            .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "frame exceeds u32::MAX"))?;
468        write_all_raw(fd, &len.to_le_bytes())?;
469        write_all_raw(fd, payload)
470    }
471
472    /// Read a 4-byte little-endian length header followed by that many bytes.
473    ///
474    /// Enforces [`MAX_FRAME_BYTES`](super::MAX_FRAME_BYTES): a length header
475    /// advertising more than the cap is rejected as `InvalidData` rather than
476    /// allocated, so a malicious/compromised child cannot OOM the parent with a
477    /// forged 4 GiB length.
478    fn recv_frame(fd: RawFd) -> io::Result<Vec<u8>> {
479        let header = read_exact_raw(fd, 4)?;
480        let len = u32::from_le_bytes([header[0], header[1], header[2], header[3]]);
481        let len = usize::try_from(len).unwrap_or(0);
482        if len == 0 {
483            return Ok(Vec::new());
484        }
485        if len > super::MAX_FRAME_BYTES {
486            return Err(io::Error::new(
487                io::ErrorKind::InvalidData,
488                format!(
489                    "ipc frame exceeds MAX_FRAME_BYTES ({}): header advertised {len} bytes",
490                    super::MAX_FRAME_BYTES
491                ),
492            ));
493        }
494        read_exact_raw(fd, len)
495    }
496
497    /// `write(2)` loop until the whole buffer is written.
498    fn write_all_raw(fd: RawFd, mut buf: &[u8]) -> io::Result<()> {
499        while !buf.is_empty() {
500            // SAFETY: `buf` is valid for `buf.len()` readable bytes; `write`
501            // reads from it and does not retain the pointer. `fd` is a valid
502            // open socket.
503            let n = unsafe { libc::write(fd, buf.as_ptr().cast::<libc::c_void>(), buf.len()) };
504            if n < 0 {
505                return Err(io::Error::last_os_error());
506            }
507            let n = usize::try_from(n).unwrap_or(0);
508            if n == 0 {
509                return Err(io::ErrorKind::WriteZero.into());
510            }
511            buf = &buf[n..];
512        }
513        Ok(())
514    }
515
516    /// `read(2)` loop until exactly `count` bytes are read.
517    fn read_exact_raw(fd: RawFd, count: usize) -> io::Result<Vec<u8>> {
518        let mut out = vec![0u8; count];
519        let mut filled = 0_usize;
520        while filled < count {
521            // SAFETY: `out[filled..]` has `count - filled` writable bytes; read
522            // writes into it and does not retain the pointer. `fd` is a valid
523            // open socket.
524            let n = unsafe {
525                libc::read(
526                    fd,
527                    out[filled..].as_mut_ptr().cast::<libc::c_void>(),
528                    count - filled,
529                )
530            };
531            if n < 0 {
532                return Err(io::Error::last_os_error());
533            }
534            let n = usize::try_from(n).unwrap_or(0);
535            if n == 0 {
536                return Err(io::ErrorKind::UnexpectedEof.into());
537            }
538            filled += n;
539        }
540        Ok(out)
541    }
542}
543
544#[cfg(target_os = "freebsd")]
545pub use imp::{spawn_isolated, SandboxedProcess};
546
547// =========================================================================
548// Non-FreeBSD: unavailable stub (keeps the public API compilable)
549// =========================================================================
550#[cfg(not(target_os = "freebsd"))]
551mod imp {
552    use std::path::Path;
553
554    use crate::error::{PluginError, Result};
555    use crate::proto::PluginMetadataPayload;
556    use crate::sandbox::SandboxConfig;
557
558    /// Placeholder for a sandboxed plugin process.
559    ///
560    /// On non-FreeBSD targets per-plugin `pdfork` isolation is unavailable:
561    /// [`spawn_isolated`] always returns an error, so no instance can ever be
562    /// constructed. The type exists only so the public API (and crate
563    /// re-exports) compiles on every target.
564    #[derive(Debug)]
565    pub struct SandboxedProcess {
566        // Private field prevents construction; the value is never created on a
567        // non-FreeBSD build (spawn_isolated always errs).
568        #[allow(dead_code)]
569        _private: (),
570    }
571
572    impl SandboxedProcess {
573        /// The plugin id. Unreachable on non-FreeBSD (no instance exists).
574        #[must_use]
575        pub fn plugin_id(&self) -> u32 {
576            0
577        }
578
579        /// Unavailable on non-FreeBSD.
580        ///
581        /// # Errors
582        ///
583        /// Always returns [`PluginError::Sandbox`].
584        pub fn request_metadata(&mut self) -> Result<PluginMetadataPayload> {
585            Err(PluginError::Sandbox(
586                "Capsicum/pdfork isolation requires FreeBSD".into(),
587            ))
588        }
589
590        /// Unavailable on non-FreeBSD.
591        ///
592        /// # Errors
593        ///
594        /// Always returns [`PluginError::Sandbox`].
595        pub fn shutdown(&mut self) -> Result<()> {
596            Err(PluginError::Sandbox(
597                "Capsicum/pdfork isolation requires FreeBSD".into(),
598            ))
599        }
600    }
601
602    /// Per-plugin process isolation is unavailable on non-FreeBSD targets.
603    ///
604    /// # Errors
605    ///
606    /// Always returns [`PluginError::Sandbox`].
607    pub fn spawn_isolated(_path: &Path, _cfg: &SandboxConfig) -> Result<SandboxedProcess> {
608        Err(PluginError::Sandbox(
609            "Capsicum/pdfork isolation requires FreeBSD; build target is not freebsd".into(),
610        ))
611    }
612}
613
614#[cfg(not(target_os = "freebsd"))]
615pub use imp::{spawn_isolated, SandboxedProcess};
616
617// =========================================================================
618// Tests
619// =========================================================================
620#[cfg(test)]
621mod tests {
622    use super::*;
623    use crate::error::PluginError;
624    use crate::sandbox::SandboxConfig;
625    use std::path::Path;
626
627    // --- Runs on every target (structural API checks) ---------------------
628
629    #[test]
630    fn max_frame_bytes_is_16_mib_dos_ceiling() {
631        // Locks the DoS ceiling: 16 MiB is generous for any 0.1.0 control
632        // message (GetMetadata/Instantiate/Process responses are a few hundred
633        // bytes) while bounding a malicious child's ability to OOM the parent
634        // via a forged 4-byte length header. A change here must be deliberate.
635        assert_eq!(super::MAX_FRAME_BYTES, 16 * 1024 * 1024);
636    }
637
638    #[test]
639    fn spawn_isolated_returns_err_off_freebsd() {
640        // On FreeBSD this may still fail (no real plugin), but returning an
641        // Err is the documented behaviour for a missing/invalid path; on
642        // non-FreeBSD it is *always* a Sandbox error regardless of the path.
643        let cfg = SandboxConfig::new();
644        let result = spawn_isolated(Path::new("/nonexistent/plugin.so"), &cfg);
645        assert!(result.is_err());
646        let err = result.unwrap_err();
647        assert!(matches!(
648            err,
649            PluginError::Sandbox(_) | PluginError::InvalidPath(_)
650        ));
651    }
652
653    #[cfg(not(target_os = "freebsd"))]
654    #[test]
655    fn spawn_isolated_error_message_mentions_freebsd_off_freebsd() {
656        let cfg = SandboxConfig::new();
657        let err = spawn_isolated(Path::new("/nonexistent/plugin.so"), &cfg).unwrap_err();
658        assert!(matches!(err, PluginError::Sandbox(_)));
659        assert!(
660            err.to_string().contains("requires FreeBSD"),
661            "error should explain the platform requirement: {err}"
662        );
663    }
664}