audio-plugin-bsd 0.1.1

Dynamic .so audio-plugin loader with ABI verification and FreeBSD Capsicum/pdfork per-process sandboxing for real-time audio in Rust
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
//! Per-plugin process isolation runtime.
//!
//! On FreeBSD each plugin can run inside its own `pdfork` child process
//! confined to a Capsicum capability sandbox. The host (parent) and the plugin
//! (child) communicate over a Unix-domain socketpair using the length-prefixed
//! framing defined in this module on top of the [`crate::proto`] messages.
//!
//! # 0.1.0 scope
//!
//! The control loop — [`Request::Ping`], [`Request::GetMetadata`],
//! [`Request::Instantiate`], [`Request::Process`], and EOF-driven shutdown —
//! is fully implemented. The child `dlopen`s the plugin, verifies its ABI,
//! copies out its metadata, restricts the IPC socket to `READ | WRITE`,
//! enters capability mode (`cap_enter`, irreversible), then serves the control
//! loop until the host closes the IPC socket.
//!
//! **Real-time audio-frame marshalling** (shared-memory transport between the
//! host RT thread and the sandboxed child) is a documented follow-up: in
//! 0.1.0 [`Request::Process`] is acknowledged with [`Response::Processed`]
//! without running actual DSP inside the child.
//!
//! # Platform availability
//!
//! [`spawn_isolated`] and [`SandboxedProcess`] are defined on every target so
//! the public API compiles everywhere, but the runtime only works on FreeBSD.
//! On non-FreeBSD targets [`spawn_isolated`] always returns
//! [`PluginError::Sandbox`] and no [`SandboxedProcess`] can ever be
//! constructed.
//!
//! # Verification
//!
//! Because Capsicum and `pdfork` are FreeBSD-only, the FreeBSD path is
//! verified by `cargo check --target x86_64-unknown-freebsd` (compilation)
//! plus Layer 4 VM regression; it is never executed on a Linux host. The
//! non-FreeBSD stub is unit-tested on Linux.

/// Maximum number of bytes the host will allocate for a single
/// length-prefixed IPC frame received from a sandboxed child.
///
/// This is a defensive ceiling against a malicious or buggy child that sends a
/// forged 4-byte length header advertising an enormous payload (e.g. 4 GiB):
/// without the cap, `recv_frame` would `vec![0u8; len]` and OOM the parent
/// (the host trusts the child only as far as Capsicum enforces — a compromised
/// child can still write arbitrary bytes on the IPC socket). 16 MiB is far
/// above any 0.1.0 control message (`GetMetadata` / `Instantiate` / `Process`
/// responses are a few hundred bytes), so a legitimate child never trips the
/// limit.
///
/// The constant lives at module scope (not inside the FreeBSD `imp` block) so
/// it can be unit-tested on every target; on non-FreeBSD builds it has no
/// runtime consumer and is therefore marked `allow(dead_code)`.
#[cfg_attr(not(target_os = "freebsd"), allow(dead_code))]
pub(crate) const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024;

// =========================================================================
// FreeBSD: pdfork + Capsicum runtime
// =========================================================================
#[cfg(target_os = "freebsd")]
mod imp {
    use capsicum::{CapRights, FileRights, Right};
    use libloading::Library;
    use std::ffi::CString;
    use std::io;
    use std::os::fd::BorrowedFd;
    use std::os::unix::io::RawFd;
    use std::path::Path;

    use crate::abi::{is_abi_compatible, AUDIO_PLUGIN_ABI_MAGIC, AUDIO_PLUGIN_ABI_VERSION};
    use crate::error::{PluginError, Result};
    use crate::metadata::PluginMetadata;
    use crate::proto::{
        decode_request, decode_response, encode_request, encode_response, PluginMetadataPayload,
        Request, Response,
    };
    use crate::sandbox::SandboxConfig;
    use crate::symbols::{
        raw_to_metadata, AbiMagicFn, AbiVersionFn, MetadataFn, AUDIO_PLUGIN_ABI_MAGIC_SYMBOL,
        AUDIO_PLUGIN_ABI_VERSION_SYMBOL, AUDIO_PLUGIN_METADATA_SYMBOL,
    };

    // NOTE: pdfork'd children never become zombies and need no `pdwait`/
    // `pdwait4`/`waitpid`: the process descriptor's terminate-on-close
    // semantics mean the kernel reaps the child when the descriptor is closed
    // (see `pdfork::ChildHandle`'s `Drop`, which runs `close(child_pd)`). The
    // child also self-terminates (`_exit`) on IPC EOF, so `shutdown`/`Drop`
    // close the IPC socket then drop the `ChildHandle` to reap.
    // (`libc` 0.2 lacks `pdwait4`, and the symbol itself was renamed to
    // `pdwait(fd, status, options, wrusage, siginfo)` in FreeBSD 14+/15, with a
    // different signature than the historical `pdwait4` — so a manual extern
    // declaration would be fragile across versions. The close-reap model is
    // both portable and the idiomatic pdfork usage.)

    /// A plugin running inside its own `pdfork` child process, confined to a
    /// Capsicum capability sandbox.
    ///
    /// The host talks to the child over the `ipc` socket. Drop (or
    /// [`shutdown`][SandboxedProcess::shutdown]) closes the IPC socket and
    /// reaps the child when the process descriptor is closed.
    #[derive(Debug)]
    pub struct SandboxedProcess {
        /// Process-descriptor handle; `None` once shutdown has reaped the child.
        handle: Option<pdfork::ChildHandle>,
        /// Parent's end of the IPC socketpair; `-1` once closed.
        ipc: RawFd,
        /// Plugin id used in IPC requests (always 0 in 0.1.0).
        plugin_id: u32,
    }

    /// Spawn an isolated plugin child process.
    ///
    /// # Steps
    ///
    /// 1. Validate `plugin_path` (must be an existing file).
    /// 2. `open(plugin_path, O_RDONLY | O_CLOEXEC)` — confirms readability
    ///    before forking and gives the parent an fd to pass to the child.
    /// 3. `socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC)` for host↔plugin IPC.
    /// 4. `pdfork()`. The child closes the parent's IPC end, `dlopen`s the
    ///    plugin (before `cap_enter`, while the global namespace is still
    ///    reachable), restricts its IPC socket to `READ | WRITE`, enters
    ///    capability mode, and serves the control loop. The parent closes the
    ///    child's IPC end and the plugin fd and returns the handle.
    ///
    /// # Errors
    ///
    /// - [`PluginError::InvalidPath`] — missing/unreadable file or NUL in path.
    /// - [`PluginError::Sandbox`] — `socketpair` / `pdfork` failure.
    /// - (Inside the child) ABI/metadata errors cause the child to `_exit(1)`
    ///   before responding; the parent observes this as an IPC error on the
    ///   first request.
    ///
    /// # Panics
    ///
    /// Never. All fallible operations are propagated via [`Result`].
    pub fn spawn_isolated(plugin_path: &Path, _cfg: &SandboxConfig) -> Result<SandboxedProcess> {
        // 1. Path validation.
        if !plugin_path.is_file() {
            return Err(PluginError::InvalidPath(format!(
                "{}: not an existing file",
                plugin_path.display()
            )));
        }
        let path_cstr = CString::new(plugin_path.as_os_str().as_encoded_bytes()).map_err(|e| {
            PluginError::InvalidPath(format!(
                "{}: path contains a NUL byte: {e}",
                plugin_path.display()
            ))
        })?;

        // 2. Open the plugin .so read-only (validates readability; the fd is
        //    inherited by the child and closed there after dlopen succeeds).
        let plugin_fd = open_readonly(&path_cstr)?;

        // 3. IPC socketpair.
        let (parent_fd, child_fd) = socketpair_cloexec()?;

        // 4. pdfork.
        match pdfork::fork() {
            pdfork::ForkResult::Child => {
                // In the child: we no longer need the parent's plugin fd (the
                // child dlopen's the path itself before cap_enter) nor the
                // parent's IPC end.
                close_fd(plugin_fd);
                // Never returns.
                child_main(plugin_path, child_fd, parent_fd);
            }
            pdfork::ForkResult::Parent(handle) => {
                // Parent keeps only its IPC end.
                close_fd(child_fd);
                close_fd(plugin_fd);
                Ok(SandboxedProcess {
                    handle: Some(handle),
                    ipc: parent_fd,
                    plugin_id: 0,
                })
            }
            pdfork::ForkResult::Fail => {
                close_fd(parent_fd);
                close_fd(child_fd);
                close_fd(plugin_fd);
                Err(PluginError::Sandbox(
                    "pdfork failed: process descriptor unavailable".into(),
                ))
            }
        }
    }

    impl SandboxedProcess {
        /// The plugin id used in IPC requests (always 0 in 0.1.0).
        #[must_use]
        pub fn plugin_id(&self) -> u32 {
            self.plugin_id
        }

        /// Request the plugin's static metadata over IPC.
        ///
        /// # Errors
        ///
        /// Returns [`PluginError::Sandbox`] on any IPC or protocol error, or
        /// when the child reports an error / unexpected response.
        pub fn request_metadata(&mut self) -> Result<PluginMetadataPayload> {
            let req = encode_request(&Request::GetMetadata {
                plugin_id: self.plugin_id,
            });
            send_frame(self.ipc, &req)
                .map_err(|e| PluginError::Sandbox(format!("ipc write: {e}")))?;
            let buf =
                recv_frame(self.ipc).map_err(|e| PluginError::Sandbox(format!("ipc read: {e}")))?;
            match decode_response(&buf)? {
                Response::Metadata { payload, .. } => Ok(payload),
                Response::Error { message, .. } => {
                    Err(PluginError::Sandbox(format!("plugin: {message}")))
                }
                other => Err(PluginError::Sandbox(format!(
                    "unexpected response to GetMetadata: {other:?}"
                ))),
            }
        }

        /// Shut the child down: close the IPC socket (the child's control loop
        /// observes EOF and `_exit`s), then drop the process descriptor, which
        /// reaps the child via pdfork's terminate-on-close semantics.
        ///
        /// Safe to call more than once (subsequent calls are no-ops). This
        /// method does not block on the child: the kernel reaps it when the
        /// descriptor closes, regardless of timing.
        ///
        /// # Errors
        ///
        /// Currently always returns `Ok`; the `Result` is kept for API parity
        /// with the non-FreeBSD stub and future synchronous-reap variants.
        pub fn shutdown(&mut self) -> Result<()> {
            if self.ipc >= 0 {
                close_fd(self.ipc);
                self.ipc = -1;
            }
            // Dropping the ChildHandle closes the process descriptor; the
            // kernel then reaps the child (pdfork processes do not zombie and
            // need no pdwait/waitpid).
            drop(self.handle.take());
            Ok(())
        }
    }

    impl Drop for SandboxedProcess {
        fn drop(&mut self) {
            // Best-effort cleanup if shutdown() was not called explicitly.
            if self.ipc >= 0 {
                close_fd(self.ipc);
                self.ipc = -1;
            }
            // Dropping the ChildHandle closes the process descriptor; the
            // kernel reaps the child via terminate-on-close.
            drop(self.handle.take());
        }
    }

    // ----- child-side entry point -----------------------------------------

    /// Child process entry point. Runs the control loop and `_exit`s; never
    /// returns.
    fn child_main(plugin_path: &Path, child_fd: RawFd, parent_fd: RawFd) -> ! {
        // Close the parent's IPC end; the child only uses `child_fd`.
        close_fd(parent_fd);

        // dlopen + ABI verify + metadata fetch BEFORE cap_enter: after entering
        // capability mode the global filesystem namespace is unreachable, so
        // path-based dlopen would fail. `_library` is intentionally kept alive
        // for the whole control loop so the plugin's symbols stay mapped.
        let Ok((_library, payload)) = load_plugin_payload(plugin_path) else {
            child_exit(1);
        };

        // Restrict the IPC socket to read + write only.
        if limit_ipc_rights(child_fd).is_err() {
            child_exit(1);
        }

        // Enter capability mode (irreversible sandbox).
        if capsicum::enter().is_err() {
            child_exit(1);
        }

        child_loop(child_fd, &payload);
        child_exit(0);
    }

    /// The control loop: read framed requests, dispatch, write framed
    /// responses, until EOF or an unrecoverable error.
    fn child_loop(child_fd: RawFd, payload: &PluginMetadataPayload) {
        loop {
            let Ok(buf) = recv_frame(child_fd) else {
                return;
            };
            let Ok(req) = decode_request(&buf) else {
                return;
            };
            let resp = dispatch_request(&req, payload);
            if send_frame(child_fd, &encode_response(&resp)).is_err() {
                return;
            }
        }
    }

    /// Map a [`Request`] to a [`Response`] (0.1.0 control surface).
    fn dispatch_request(req: &Request, payload: &PluginMetadataPayload) -> Response {
        match req {
            Request::Ping => Response::Pong,
            Request::GetMetadata { plugin_id } => Response::Metadata {
                plugin_id: *plugin_id,
                payload: payload.clone(),
            },
            Request::Instantiate { plugin_id } => Response::InstanceCreated {
                plugin_id: *plugin_id,
            },
            // 0.1.0: acknowledge without running DSP (RT frame marshalling is
            // a documented follow-up).
            Request::Process { plugin_id, .. } => Response::Processed {
                plugin_id: *plugin_id,
            },
        }
    }

    /// Terminate the child process immediately without running destructors or
    /// `atexit` handlers, as required after `fork` for async-signal-safety.
    fn child_exit(code: i32) -> ! {
        // SAFETY: `_exit` is async-signal-safe and the correct way to terminate
        // a forked child. It does not return.
        unsafe {
            libc::_exit(code);
        }
    }

    // ----- plugin load (child side) ---------------------------------------

    /// `dlopen` the plugin, verify its ABI, and copy out its metadata as a
    /// [`PluginMetadataPayload`]. Returns the live `Library` alongside the
    /// payload so the caller can keep it mapped.
    fn load_plugin_payload(path: &Path) -> Result<(Library, PluginMetadataPayload)> {
        let library = unsafe { Library::new(path) }
            .map_err(|e| PluginError::LibraryLoad(format!("{}: {e}", path.display())))?;

        // ABI magic.
        let plugin_magic = unsafe {
            let sym: libloading::Symbol<AbiMagicFn> = library
                .get(AUDIO_PLUGIN_ABI_MAGIC_SYMBOL.as_bytes())
                .map_err(|e| {
                    PluginError::SymbolMissing(format!("{AUDIO_PLUGIN_ABI_MAGIC_SYMBOL}: {e}"))
                })?;
            sym()
        };
        if plugin_magic != AUDIO_PLUGIN_ABI_MAGIC {
            return Err(PluginError::InvalidMetadata(format!(
                "ABI magic mismatch: expected {AUDIO_PLUGIN_ABI_MAGIC:#010x}, got {plugin_magic:#010x}"
            )));
        }

        // ABI version (major must match host).
        let plugin_version = unsafe {
            let sym: libloading::Symbol<AbiVersionFn> = library
                .get(AUDIO_PLUGIN_ABI_VERSION_SYMBOL.as_bytes())
                .map_err(|e| {
                    PluginError::SymbolMissing(format!("{AUDIO_PLUGIN_ABI_VERSION_SYMBOL}: {e}"))
                })?;
            sym()
        };
        if !is_abi_compatible(AUDIO_PLUGIN_ABI_VERSION, plugin_version) {
            return Err(PluginError::AbiVersionMismatch {
                host: AUDIO_PLUGIN_ABI_VERSION,
                plugin: plugin_version,
            });
        }

        // Metadata.
        let md: PluginMetadata = unsafe {
            let sym: libloading::Symbol<MetadataFn> = library
                .get(AUDIO_PLUGIN_METADATA_SYMBOL.as_bytes())
                .map_err(|e| {
                    PluginError::SymbolMissing(format!("{AUDIO_PLUGIN_METADATA_SYMBOL}: {e}"))
                })?;
            // SAFETY: `sym()` returns the plugin's 'static metadata pointer;
            // the library (and thus the plugin's data segment) is alive because
            // `library` is in scope and outlives this call.
            raw_to_metadata(sym())?
        };

        let payload = PluginMetadataPayload {
            name: md.name,
            version: md.version,
            description: md.description,
            abi_version: md.abi_version,
        };
        Ok((library, payload))
    }

    // ----- Capsicum helpers -----------------------------------------------

    /// Restrict `fd` to `READ | WRITE` capability rights.
    fn limit_ipc_rights(fd: RawFd) -> io::Result<()> {
        let mut rights = FileRights::new();
        rights.allow(Right::Read);
        rights.allow(Right::Write);
        // SAFETY: `fd` is a valid, open file descriptor (the child's IPC socket
        // end) inherited from the parent. `BorrowedFd::borrow_raw` requires the
        // fd be valid for the duration of the borrow, which holds for this call.
        let borrowed = unsafe { BorrowedFd::borrow_raw(fd) };
        rights.limit(&borrowed)
    }

    // ----- raw fd / syscall helpers ---------------------------------------

    /// Open `path` read-only with `O_CLOEXEC`.
    fn open_readonly(path: &CString) -> Result<RawFd> {
        // SAFETY: `path` is a valid NUL-terminated C string owned by the caller;
        // `open` does not retain the pointer beyond the call.
        let fd = unsafe { libc::open(path.as_ptr(), libc::O_RDONLY | libc::O_CLOEXEC) };
        if fd < 0 {
            Err(PluginError::InvalidPath(format!(
                "{}: cannot open for reading: {}",
                path.to_string_lossy(),
                io::Error::last_os_error()
            )))
        } else {
            Ok(fd)
        }
    }

    /// Create a `SOCK_STREAM | SOCK_CLOEXEC` Unix-domain socketpair.
    fn socketpair_cloexec() -> Result<(RawFd, RawFd)> {
        let mut fds = [0_i32; 2];
        // SAFETY: `fds` is a valid 2-element array; socketpair writes two fds
        // or returns -1. The pointer is not retained.
        let rc = unsafe {
            libc::socketpair(
                libc::AF_UNIX,
                libc::SOCK_STREAM | libc::SOCK_CLOEXEC,
                0,
                fds.as_mut_ptr(),
            )
        };
        if rc < 0 {
            Err(PluginError::Sandbox(format!(
                "socketpair failed: {}",
                io::Error::last_os_error()
            )))
        } else {
            Ok((fds[0], fds[1]))
        }
    }

    /// Idempotent close: ignores `-1` and `EBADF`.
    fn close_fd(fd: RawFd) {
        if fd >= 0 {
            // SAFETY: `fd` is either a valid open descriptor or already-closed
            // (close on a bad fd sets EBADF, which we discard). `close` does not
            // retain anything.
            unsafe {
                let _ = libc::close(fd);
            }
        }
    }

    // ----- length-prefixed IPC framing ------------------------------------

    /// Write a 4-byte little-endian length header followed by `payload`.
    fn send_frame(fd: RawFd, payload: &[u8]) -> io::Result<()> {
        let len = u32::try_from(payload.len())
            .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "frame exceeds u32::MAX"))?;
        write_all_raw(fd, &len.to_le_bytes())?;
        write_all_raw(fd, payload)
    }

    /// Read a 4-byte little-endian length header followed by that many bytes.
    ///
    /// Enforces [`MAX_FRAME_BYTES`](super::MAX_FRAME_BYTES): a length header
    /// advertising more than the cap is rejected as `InvalidData` rather than
    /// allocated, so a malicious/compromised child cannot OOM the parent with a
    /// forged 4 GiB length.
    fn recv_frame(fd: RawFd) -> io::Result<Vec<u8>> {
        let header = read_exact_raw(fd, 4)?;
        let len = u32::from_le_bytes([header[0], header[1], header[2], header[3]]);
        let len = usize::try_from(len).unwrap_or(0);
        if len == 0 {
            return Ok(Vec::new());
        }
        if len > super::MAX_FRAME_BYTES {
            return Err(io::Error::new(
                io::ErrorKind::InvalidData,
                format!(
                    "ipc frame exceeds MAX_FRAME_BYTES ({}): header advertised {len} bytes",
                    super::MAX_FRAME_BYTES
                ),
            ));
        }
        read_exact_raw(fd, len)
    }

    /// `write(2)` loop until the whole buffer is written.
    fn write_all_raw(fd: RawFd, mut buf: &[u8]) -> io::Result<()> {
        while !buf.is_empty() {
            // SAFETY: `buf` is valid for `buf.len()` readable bytes; `write`
            // reads from it and does not retain the pointer. `fd` is a valid
            // open socket.
            let n = unsafe { libc::write(fd, buf.as_ptr().cast::<libc::c_void>(), buf.len()) };
            if n < 0 {
                return Err(io::Error::last_os_error());
            }
            let n = usize::try_from(n).unwrap_or(0);
            if n == 0 {
                return Err(io::ErrorKind::WriteZero.into());
            }
            buf = &buf[n..];
        }
        Ok(())
    }

    /// `read(2)` loop until exactly `count` bytes are read.
    fn read_exact_raw(fd: RawFd, count: usize) -> io::Result<Vec<u8>> {
        let mut out = vec![0u8; count];
        let mut filled = 0_usize;
        while filled < count {
            // SAFETY: `out[filled..]` has `count - filled` writable bytes; read
            // writes into it and does not retain the pointer. `fd` is a valid
            // open socket.
            let n = unsafe {
                libc::read(
                    fd,
                    out[filled..].as_mut_ptr().cast::<libc::c_void>(),
                    count - filled,
                )
            };
            if n < 0 {
                return Err(io::Error::last_os_error());
            }
            let n = usize::try_from(n).unwrap_or(0);
            if n == 0 {
                return Err(io::ErrorKind::UnexpectedEof.into());
            }
            filled += n;
        }
        Ok(out)
    }
}

#[cfg(target_os = "freebsd")]
pub use imp::{spawn_isolated, SandboxedProcess};

// =========================================================================
// Non-FreeBSD: unavailable stub (keeps the public API compilable)
// =========================================================================
#[cfg(not(target_os = "freebsd"))]
mod imp {
    use std::path::Path;

    use crate::error::{PluginError, Result};
    use crate::proto::PluginMetadataPayload;
    use crate::sandbox::SandboxConfig;

    /// Placeholder for a sandboxed plugin process.
    ///
    /// On non-FreeBSD targets per-plugin `pdfork` isolation is unavailable:
    /// [`spawn_isolated`] always returns an error, so no instance can ever be
    /// constructed. The type exists only so the public API (and crate
    /// re-exports) compiles on every target.
    #[derive(Debug)]
    pub struct SandboxedProcess {
        // Private field prevents construction; the value is never created on a
        // non-FreeBSD build (spawn_isolated always errs).
        #[allow(dead_code)]
        _private: (),
    }

    impl SandboxedProcess {
        /// The plugin id. Unreachable on non-FreeBSD (no instance exists).
        #[must_use]
        pub fn plugin_id(&self) -> u32 {
            0
        }

        /// Unavailable on non-FreeBSD.
        ///
        /// # Errors
        ///
        /// Always returns [`PluginError::Sandbox`].
        pub fn request_metadata(&mut self) -> Result<PluginMetadataPayload> {
            Err(PluginError::Sandbox(
                "Capsicum/pdfork isolation requires FreeBSD".into(),
            ))
        }

        /// Unavailable on non-FreeBSD.
        ///
        /// # Errors
        ///
        /// Always returns [`PluginError::Sandbox`].
        pub fn shutdown(&mut self) -> Result<()> {
            Err(PluginError::Sandbox(
                "Capsicum/pdfork isolation requires FreeBSD".into(),
            ))
        }
    }

    /// Per-plugin process isolation is unavailable on non-FreeBSD targets.
    ///
    /// # Errors
    ///
    /// Always returns [`PluginError::Sandbox`].
    pub fn spawn_isolated(_path: &Path, _cfg: &SandboxConfig) -> Result<SandboxedProcess> {
        Err(PluginError::Sandbox(
            "Capsicum/pdfork isolation requires FreeBSD; build target is not freebsd".into(),
        ))
    }
}

#[cfg(not(target_os = "freebsd"))]
pub use imp::{spawn_isolated, SandboxedProcess};

// =========================================================================
// Tests
// =========================================================================
#[cfg(test)]
mod tests {
    use super::*;
    use crate::error::PluginError;
    use crate::sandbox::SandboxConfig;
    use std::path::Path;

    // --- Runs on every target (structural API checks) ---------------------

    #[test]
    fn max_frame_bytes_is_16_mib_dos_ceiling() {
        // Locks the DoS ceiling: 16 MiB is generous for any 0.1.0 control
        // message (GetMetadata/Instantiate/Process responses are a few hundred
        // bytes) while bounding a malicious child's ability to OOM the parent
        // via a forged 4-byte length header. A change here must be deliberate.
        assert_eq!(super::MAX_FRAME_BYTES, 16 * 1024 * 1024);
    }

    #[test]
    fn spawn_isolated_returns_err_off_freebsd() {
        // On FreeBSD this may still fail (no real plugin), but returning an
        // Err is the documented behaviour for a missing/invalid path; on
        // non-FreeBSD it is *always* a Sandbox error regardless of the path.
        let cfg = SandboxConfig::new();
        let result = spawn_isolated(Path::new("/nonexistent/plugin.so"), &cfg);
        assert!(result.is_err());
        let err = result.unwrap_err();
        assert!(matches!(
            err,
            PluginError::Sandbox(_) | PluginError::InvalidPath(_)
        ));
    }

    #[cfg(not(target_os = "freebsd"))]
    #[test]
    fn spawn_isolated_error_message_mentions_freebsd_off_freebsd() {
        let cfg = SandboxConfig::new();
        let err = spawn_isolated(Path::new("/nonexistent/plugin.so"), &cfg).unwrap_err();
        assert!(matches!(err, PluginError::Sandbox(_)));
        assert!(
            err.to_string().contains("requires FreeBSD"),
            "error should explain the platform requirement: {err}"
        );
    }
}