#[cfg_attr(not(target_os = "freebsd"), allow(dead_code))]
pub(crate) const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024;
#[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,
};
#[derive(Debug)]
pub struct SandboxedProcess {
handle: Option<pdfork::ChildHandle>,
ipc: RawFd,
plugin_id: u32,
}
pub fn spawn_isolated(plugin_path: &Path, _cfg: &SandboxConfig) -> Result<SandboxedProcess> {
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()
))
})?;
let plugin_fd = open_readonly(&path_cstr)?;
let (parent_fd, child_fd) = socketpair_cloexec()?;
match pdfork::fork() {
pdfork::ForkResult::Child => {
close_fd(plugin_fd);
child_main(plugin_path, child_fd, parent_fd);
}
pdfork::ForkResult::Parent(handle) => {
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 {
#[must_use]
pub fn plugin_id(&self) -> u32 {
self.plugin_id
}
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:?}"
))),
}
}
pub fn shutdown(&mut self) -> Result<()> {
if self.ipc >= 0 {
close_fd(self.ipc);
self.ipc = -1;
}
drop(self.handle.take());
Ok(())
}
}
impl Drop for SandboxedProcess {
fn drop(&mut self) {
if self.ipc >= 0 {
close_fd(self.ipc);
self.ipc = -1;
}
drop(self.handle.take());
}
}
fn child_main(plugin_path: &Path, child_fd: RawFd, parent_fd: RawFd) -> ! {
close_fd(parent_fd);
let Ok((_library, payload)) = load_plugin_payload(plugin_path) else {
child_exit(1);
};
if limit_ipc_rights(child_fd).is_err() {
child_exit(1);
}
if capsicum::enter().is_err() {
child_exit(1);
}
child_loop(child_fd, &payload);
child_exit(0);
}
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;
}
}
}
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,
},
Request::Process { plugin_id, .. } => Response::Processed {
plugin_id: *plugin_id,
},
}
}
fn child_exit(code: i32) -> ! {
unsafe {
libc::_exit(code);
}
}
fn load_plugin_payload(path: &Path) -> Result<(Library, PluginMetadataPayload)> {
let library = unsafe { Library::new(path) }
.map_err(|e| PluginError::LibraryLoad(format!("{}: {e}", path.display())))?;
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}"
)));
}
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,
});
}
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}"))
})?;
raw_to_metadata(sym())?
};
let payload = PluginMetadataPayload {
name: md.name,
version: md.version,
description: md.description,
abi_version: md.abi_version,
};
Ok((library, payload))
}
fn limit_ipc_rights(fd: RawFd) -> io::Result<()> {
let mut rights = FileRights::new();
rights.allow(Right::Read);
rights.allow(Right::Write);
let borrowed = unsafe { BorrowedFd::borrow_raw(fd) };
rights.limit(&borrowed)
}
fn open_readonly(path: &CString) -> Result<RawFd> {
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)
}
}
fn socketpair_cloexec() -> Result<(RawFd, RawFd)> {
let mut fds = [0_i32; 2];
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]))
}
}
fn close_fd(fd: RawFd) {
if fd >= 0 {
unsafe {
let _ = libc::close(fd);
}
}
}
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)
}
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)
}
fn write_all_raw(fd: RawFd, mut buf: &[u8]) -> io::Result<()> {
while !buf.is_empty() {
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(())
}
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 {
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};
#[cfg(not(target_os = "freebsd"))]
mod imp {
use std::path::Path;
use crate::error::{PluginError, Result};
use crate::proto::PluginMetadataPayload;
use crate::sandbox::SandboxConfig;
#[derive(Debug)]
pub struct SandboxedProcess {
#[allow(dead_code)]
_private: (),
}
impl SandboxedProcess {
#[must_use]
pub fn plugin_id(&self) -> u32 {
0
}
pub fn request_metadata(&mut self) -> Result<PluginMetadataPayload> {
Err(PluginError::Sandbox(
"Capsicum/pdfork isolation requires FreeBSD".into(),
))
}
pub fn shutdown(&mut self) -> Result<()> {
Err(PluginError::Sandbox(
"Capsicum/pdfork isolation requires FreeBSD".into(),
))
}
}
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};
#[cfg(test)]
mod tests {
use super::*;
use crate::error::PluginError;
use crate::sandbox::SandboxConfig;
use std::path::Path;
#[test]
fn max_frame_bytes_is_16_mib_dos_ceiling() {
assert_eq!(super::MAX_FRAME_BYTES, 16 * 1024 * 1024);
}
#[test]
fn spawn_isolated_returns_err_off_freebsd() {
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}"
);
}
}