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
//! C ABI symbol contract: the entry symbols every plugin must expose, their
//! function-pointer types, and the `#[repr(C)]` metadata struct returned by
//! `audio_plugin_metadata`.
//!
//! Plugins cross the `.so` boundary exclusively through the plain `extern "C"`
//! symbols named here. The host never reinterpret-casts the returned pointers
//! as Rust trait objects (that would be undefined behaviour); it only calls
//! further `extern "C"` functions on them.
//!
//! # Required symbols
//!
//! | Symbol | C signature | Purpose |
//! |---|---|---|
//! | [`AUDIO_PLUGIN_ABI_MAGIC_SYMBOL`] | `unsafe extern "C" fn() -> u32` | Returns [`AUDIO_PLUGIN_ABI_MAGIC`]. |
//! | [`AUDIO_PLUGIN_ABI_VERSION_SYMBOL`] | `unsafe extern "C" fn() -> u32` | Encoded `(major, minor)` word. |
//! | [`AUDIO_PLUGIN_METADATA_SYMBOL`] | `unsafe extern "C" fn() -> *const RawPluginMetadata` | Static identity. |
//! | [`AUDIO_PLUGIN_CREATE_SYMBOL`] | `unsafe extern "C" fn() -> *mut c_void` | Allocate an opaque plugin handle. |
//!
//! # Optional symbol
//!
//! | Symbol | C signature | Purpose |
//! |---|---|---|
//! | [`AUDIO_PLUGIN_DESTROY_SYMBOL`] | `unsafe extern "C" fn(*mut c_void)` | Free a handle from `create`. If absent the handle leaks (documented 0.1.0 limitation). |

use crate::error::{PluginError, Result};
use crate::metadata::PluginMetadata;

/// Symbol name a plugin exposes to report the ABI magic word.
pub const AUDIO_PLUGIN_ABI_MAGIC_SYMBOL: &str = "audio_plugin_abi_magic";

/// Symbol name a plugin exposes to report its encoded ABI version.
pub const AUDIO_PLUGIN_ABI_VERSION_SYMBOL: &str = "audio_plugin_abi_version";

/// Symbol name a plugin exposes to report its static metadata.
pub const AUDIO_PLUGIN_METADATA_SYMBOL: &str = "audio_plugin_metadata";

/// Symbol name a plugin exposes to construct an opaque plugin handle.
pub const AUDIO_PLUGIN_CREATE_SYMBOL: &str = "audio_plugin_create";

/// Optional symbol name a plugin exposes to free an opaque plugin handle.
///
/// When absent, [`PluginLoader`](crate::PluginLoader) cannot reclaim the
/// handle and it leaks. This is a documented limitation of the 0.1.0
/// ABI surface.
pub const AUDIO_PLUGIN_DESTROY_SYMBOL: &str = "audio_plugin_destroy";

/// `unsafe extern "C" fn() -> u32` — returns the ABI magic word.
pub type AbiMagicFn = unsafe extern "C" fn() -> u32;

/// `unsafe extern "C" fn() -> u32` — returns the encoded ABI version word.
pub type AbiVersionFn = unsafe extern "C" fn() -> u32;

/// `unsafe extern "C" fn() -> *const RawPluginMetadata` — returns a pointer to
/// the plugin's static metadata (owned by the plugin for the lifetime of the
/// loaded library).
pub type MetadataFn = unsafe extern "C" fn() -> *const RawPluginMetadata;

/// `unsafe extern "C" fn() -> *mut c_void` — allocates and returns an opaque
/// plugin handle.
pub type CreateFn = unsafe extern "C" fn() -> *mut core::ffi::c_void;

/// `unsafe extern "C" fn(*mut c_void)` — frees an opaque plugin handle
/// previously returned by [`CreateFn`].
pub type DestroyFn = unsafe extern "C" fn(*mut core::ffi::c_void);

/// `#[repr(C)]` metadata struct returned by `audio_plugin_metadata`.
///
/// Each string is a `(ptr, len)` pair pointing into a byte buffer owned by the
/// plugin for the lifetime of the loaded library (`'static` from the host's
/// perspective). The host copies the bytes out into owned [`String`]s
/// immediately (see [`raw_to_metadata`]) and never retains the pointers.
///
/// # Layout
///
/// The fields are ordered to match the C declaration plugin authors write:
///
/// ```c
/// struct raw_plugin_metadata {
///     const uint8_t *name_ptr;        size_t name_len;
///     const uint8_t *version_ptr;     size_t version_len;
///     const uint8_t *description_ptr; size_t description_len;
///     uint32_t abi_version;
/// };
/// ```
#[repr(C)]
pub struct RawPluginMetadata {
    /// Pointer to the UTF-8 plugin name bytes.
    pub name_ptr: *const u8,
    /// Length in bytes of the `name_ptr` range.
    pub name_len: usize,
    /// Pointer to the UTF-8 version bytes.
    pub version_ptr: *const u8,
    /// Length in bytes of the `version_ptr` range.
    pub version_len: usize,
    /// Pointer to the UTF-8 description bytes.
    pub description_ptr: *const u8,
    /// Length in bytes of the `description_ptr` range.
    pub description_len: usize,
    /// Encoded ABI version the plugin was built against.
    pub abi_version: u32,
}

/// Copy a `(ptr, len)` byte range into an owned [`String`], validating UTF-8.
///
/// Returns [`PluginError::InvalidMetadata`] when the pointer is null or the
/// bytes are not valid UTF-8.
///
/// # Safety
///
/// `ptr` must be null or point to at least `len` readable bytes that remain
/// valid for the duration of this call.
unsafe fn ptr_len_to_string(ptr: *const u8, len: usize, field: &'static str) -> Result<String> {
    if ptr.is_null() {
        return Err(PluginError::InvalidMetadata(format!(
            "{field} pointer is null"
        )));
    }
    // SAFETY: caller guarantees `ptr` points to `len` readable bytes.
    let bytes = unsafe { core::slice::from_raw_parts(ptr, len) };
    match std::str::from_utf8(bytes) {
        Ok(s) => Ok(s.to_owned()),
        Err(_) => Err(PluginError::InvalidMetadata(format!(
            "{field} is not valid UTF-8"
        ))),
    }
}

/// Convert a raw FFI metadata pointer into an owned [`PluginMetadata`].
///
/// Copies every string field out of plugin-owned memory into host-owned
/// [`String`]s, so the returned value is independent of the plugin's lifetime.
/// `author` and `license` are not part of the 0.1.0 ABI struct and default to
/// [`None`].
///
/// # Safety
///
/// `raw` must be null or point to a valid [`RawPluginMetadata`] whose string
/// `(ptr, len)` fields reference readable byte ranges valid for the duration
/// of this call (typically the lifetime of the loaded plugin library).
///
/// # Errors
///
/// Returns [`PluginError::InvalidMetadata`] when `raw` is null or any string
/// field is null or not valid UTF-8.
pub unsafe fn raw_to_metadata(raw: *const RawPluginMetadata) -> Result<PluginMetadata> {
    if raw.is_null() {
        return Err(PluginError::InvalidMetadata(
            "metadata symbol returned a null pointer".into(),
        ));
    }
    // SAFETY: caller guarantees `raw` is a valid, non-null pointer to a
    // `RawPluginMetadata` whose fields are readable for this call.
    let raw = unsafe { &*raw };
    let name = unsafe { ptr_len_to_string(raw.name_ptr, raw.name_len, "name")? };
    let version = unsafe { ptr_len_to_string(raw.version_ptr, raw.version_len, "version")? };
    let description =
        unsafe { ptr_len_to_string(raw.description_ptr, raw.description_len, "description")? };
    Ok(PluginMetadata::new(
        name,
        version,
        description,
        raw.abi_version,
    ))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn symbol_name_constants_match_extern_c_convention() {
        assert_eq!(AUDIO_PLUGIN_ABI_MAGIC_SYMBOL, "audio_plugin_abi_magic");
        assert_eq!(AUDIO_PLUGIN_ABI_VERSION_SYMBOL, "audio_plugin_abi_version");
        assert_eq!(AUDIO_PLUGIN_METADATA_SYMBOL, "audio_plugin_metadata");
        assert_eq!(AUDIO_PLUGIN_CREATE_SYMBOL, "audio_plugin_create");
        assert_eq!(AUDIO_PLUGIN_DESTROY_SYMBOL, "audio_plugin_destroy");
    }

    #[test]
    fn raw_to_metadata_null_pointer_is_err() {
        // SAFETY: passing a null pointer is explicitly handled.
        let err = unsafe { raw_to_metadata(core::ptr::null()) }.unwrap_err();
        assert!(matches!(err, PluginError::InvalidMetadata(_)));
        assert!(err.to_string().contains("null pointer"));
    }

    #[test]
    fn raw_to_metadata_roundtrips_owned_strings() {
        let name = b"reverb-plate";
        let version = b"0.3.1";
        let description = b"plate reverb DSP";
        let raw = RawPluginMetadata {
            name_ptr: name.as_ptr(),
            name_len: name.len(),
            version_ptr: version.as_ptr(),
            version_len: version.len(),
            description_ptr: description.as_ptr(),
            description_len: description.len(),
            abi_version: 0x0001_0000,
        };
        // SAFETY: `raw` is a valid stack pointer whose string fields point into
        // the static byte slices above, valid for this call.
        let md = unsafe { raw_to_metadata(std::ptr::addr_of!(raw)) }.expect("valid metadata");
        assert_eq!(md.name, "reverb-plate");
        assert_eq!(md.version, "0.3.1");
        assert_eq!(md.description, "plate reverb DSP");
        assert_eq!(md.abi_version, 0x0001_0000);
        assert_eq!(md.author, None);
        assert_eq!(md.license, None);
    }

    #[test]
    fn raw_to_metadata_null_string_pointer_is_err() {
        let valid = b"x";
        let raw = RawPluginMetadata {
            name_ptr: core::ptr::null(),
            name_len: 0,
            version_ptr: valid.as_ptr(),
            version_len: valid.len(),
            description_ptr: valid.as_ptr(),
            description_len: valid.len(),
            abi_version: 0,
        };
        // SAFETY: pointer is valid; one inner field is deliberately null.
        let err = unsafe { raw_to_metadata(std::ptr::addr_of!(raw)) }.unwrap_err();
        assert!(matches!(err, PluginError::InvalidMetadata(_)));
        assert!(err.to_string().contains("name pointer is null"));
    }

    #[test]
    fn raw_to_metadata_invalid_utf8_is_err() {
        // 0xFF is never valid UTF-8 on its own.
        let bad = [0xFFu8, 0xFE, 0xFD];
        let raw = RawPluginMetadata {
            name_ptr: bad.as_ptr(),
            name_len: bad.len(),
            version_ptr: b"1".as_ptr(),
            version_len: 1,
            description_ptr: b"d".as_ptr(),
            description_len: 1,
            abi_version: 0,
        };
        // SAFETY: pointer is valid; the name bytes are deliberately invalid UTF-8.
        let err = unsafe { raw_to_metadata(std::ptr::addr_of!(raw)) }.unwrap_err();
        assert!(matches!(err, PluginError::InvalidMetadata(_)));
        assert!(err.to_string().contains("name is not valid UTF-8"));
    }
}