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}