polyplug_guest 0.1.1

Guest-side Rust bindings for plugin authors using the polyplug runtime
docs.rs failed to build polyplug_guest-0.1.1
Please check the build logs for more information.
See Builds for ideas on how to fix a failed build, or Metadata for how to configure docs.rs builds.
If you believe this is docs.rs' fault, open an issue.

polyplug-guest — Rust Guest Library

The polyplug_guest crate provides the types, constants, and allocator helpers that plugin authors need to implement a polyplug contract in Rust.

Who is this for? You are writing a shared library (.so / .dylib / .dll) that will be loaded by a polyplug host at runtime. You are not writing a host application — see crates/polyplug/ for that.


Table of Contents


Quick Start

Add polyplug_guest to your plugin's Cargo.toml, set crate-type = ["cdylib"], then export two symbols: polyplug_abi_version and polyplug_init.

# Cargo.toml
[package]
name    = "my_plugin"
version = "1.0.0"
edition = "2024"

[lib]
crate-type = ["cdylib"]

[dependencies]
polyplug_abi   = { workspace = true }
polyplug_guest = { workspace = true }
polyplug_utils = { workspace = true }
// src/lib.rs
use polyplug_abi::*;
use polyplug_guest::FnPtr;
use polyplug_utils::GuestContractId;

// FNV-1a of "guest_contract:my.contract@1" — see polyplug_utils::guest_contract_id
const MY_CONTRACT_ID: u64 = 0x3AFC01CA348E3F0D;

extern "C" fn my_fn(args: *const (), out: *mut ()) -> AbiError {
    // ... read from args, write result to out
    AbiError::ok()
}

static MY_FNS: [FnPtr; 1] = [FnPtr(my_fn as *const ())];

static MY_VTABLE: GuestContractInterface = GuestContractInterface {
    contract_id: GuestContractId::from_u64(MY_CONTRACT_ID),
    contract_version: Version { major: 1, minor: 0, patch: 0 },
    dispatch_type: DispatchType::Native,
    create_instance: my_create_instance,
    destroy_instance: my_destroy_instance,
    dispatch: DispatchMechanisms {
        native: NativeDispatch {
            function_count: 1,
            functions: MY_FNS.as_ptr() as *const *const (),
        },
    },
};

static MY_DESCRIPTOR: PluginDescriptor = PluginDescriptor {
    name:          StringView { ptr: b"my_plugin".as_ptr(), len: 9 },
    contract_name: StringView { ptr: b"my.contract".as_ptr(), len: 11 },
    version:       Version { major: 1, minor: 0, patch: 0 },
};

#[unsafe(no_mangle)]
pub extern "C" fn polyplug_abi_version() -> u32 {
    POLYPLUG_ABI_VERSION
}

#[unsafe(no_mangle)]
pub unsafe extern "C" fn polyplug_init(
    host: *const HostApi,
    _ctx: *const BundleInitContext,
) -> AbiError {
    if host.is_null() {
        return AbiError { code: AbiErrorCode::Generic as u32, message: StringView::null() };
    }
    // SAFETY: host is non-null and provided by the host per ABI contract.
    let iface: &HostApi = unsafe { &*host };
    // SAFETY: register_guest_contract is a valid function pointer set by the host.
    unsafe { (iface.register_guest_contract)(host, &MY_DESCRIPTOR, &MY_VTABLE) }
}

Cargo.toml Setup

Your plugin must be a cdylib:

[lib]
crate-type = ["cdylib"]

Important: A plugin cdylib must never depend on the host-side polyplug crate — the host already embeds polyplug, and a plugin only needs the ABI surface. Depend on polyplug_abi (the #[repr(C)] ABI types), polyplug_guest (guest-side helpers), and polyplug_utils (ID hashing), as the workspace examples under examples/guests/rust/ do. A plugin distributed outside this workspace can instead mirror the #[repr(C)] ABI types inline — the ABI is defined by struct layout, not by linkage.


Available Types

The ABI types live in the polyplug_abi crate — import them from there directly (use polyplug_abi::*; or selectively). polyplug_guest adds the guest-side helpers (FnPtr, GuestError, HostContext, to_str, …); it does not re-export polyplug_abi (the workspace is cross-crate-re-export free).

StringView

Non-owning, non-null-terminated UTF-8 string reference.

#[repr(C)]
pub struct StringView {
    pub ptr: *const u8,
    pub len: usize,
}

Ownership: Borrowed. ptr must remain valid for the duration of the call. The receiver must not free it.

// Create from a Rust string slice (valid for the call's lifetime):
let s: &str = "hello";
let sv = StringView { ptr: s.as_ptr(), len: s.len() };

// Create a null view (empty / absent):
let null_sv: StringView = StringView::null();

Buffer

Owning byte buffer allocated via the host allocator.

#[repr(C)]
pub struct Buffer {
    pub ptr: *mut u8,
    pub len: usize,
    pub cap: usize,
}

Ownership: The holder must free with host.free(host, ptr, cap, 1) when done. Never place Buffer data on the Rust allocator — always allocate through the host's HostApi (host.alloc, e.g. via HostContext::alloc_string).


AbiError

Returned by value from all ABI calls.

#[repr(C)]
pub struct AbiError {
    pub code: u32,
    pub message: StringView,
}
  • code == 0 → success (AbiErrorCode::Ok).
  • code != 0 → failure; message provides a human-readable description.
  • Ownership of message.ptr: always a static or runtime-owned string. The receiver never frees it (a bare StringView carries no allocation provenance). For rich, allocated error detail, use get_last_error instead.

Convenience constructors:

AbiError::ok()   // AbiError { code: 0, message: StringView::null() }

GuestContractInterface

One per contract your plugin implements. Must be 'static.

#[repr(C)]
pub struct GuestContractInterface {
    pub contract_id:      GuestContractId, // newtype around u64 (polyplug_utils)
    pub contract_version: Version,     // { major: u32, minor: u32, patch: u32 }
    pub dispatch_type:    DispatchType, // Native (0) or VirtualMachine (1)
    pub create_instance:  unsafe extern "C" fn(
        host: *const HostApi,
        args: *const (),
    ) -> GuestContractInstance,
    pub destroy_instance: unsafe extern "C" fn(
        host: *const HostApi,
        instance: GuestContractInstance,
    ),
    pub dispatch:         DispatchMechanisms, // union of NativeDispatch or VmDispatch
}
  • contract_id is the FNV-1a 64-bit hash of "guest_contract:name@major_version" (compute it with polyplug_utils::guest_contract_id, wrap it with GuestContractId::from_u64).
  • dispatch_type determines how to access the dispatch union:
    • Native — use dispatch.native.functions[fn_id] for direct function pointer calls.
    • VirtualMachine — use dispatch.vm.call(loader_data, instance, fn_id, args, out).
  • create_instance / destroy_instance manage instance lifecycle (required for hot-reload).

PluginDescriptor

Metadata about your plugin. Must be 'static.

#[repr(C)]
pub struct PluginDescriptor {
    pub name:          StringView,   // plugin instance name
    pub contract_name: StringView,   // e.g. "pipeline.transformer"
    pub version:       Version,      // { major: u32, minor: u32, patch: u32 }
}

HostApi

Passed by the host to your polyplug_init and to every create_instance call. The pointer stays valid for the plugin's lifetime. The generated glue wraps it in a polyplug_guest::HostContext and hands it to your author factory (polyplug_create_<plugin>); store that context in your implementation struct for allocation, logging, and cross-contract calls. There is NO process-wide host storage in this SDK — the pointer always flows through instances.

#[repr(C)]
pub struct HostApi {
    pub runtime: *mut c_void,
    pub register_guest_contract: unsafe extern "C" fn(
        this:        *const HostApi,
        descriptor:  *const PluginDescriptor,
        interface:   *const GuestContractInterface,
    ) -> AbiError,
    pub alloc: unsafe extern "C" fn(this: *const HostApi, size: usize, align: usize) -> *mut u8,
    pub free: unsafe extern "C" fn(this: *const HostApi, ptr: *mut u8, size: usize, align: usize),
    pub find_guest_contract: unsafe extern "C" fn(
        this: *const HostApi,
        contract_id: u64,
        min_version: u32,
    ) -> GuestContractHandle,
    pub find_all_guest_contracts: unsafe extern "C" fn(
        this: *const HostApi,
        contract_id: u64,
        min_version: u32,
    ) -> Array<GuestContractHandle>,
    pub resolve_guest_contract: unsafe extern "C" fn(
        this: *const HostApi,
        handle: GuestContractHandle,
    ) -> *const GuestContractInterface,
    pub get_host_contract: unsafe extern "C" fn(
        this: *const HostApi,
        contract_id: u64,
        min_version: u32,
    ) -> HostContractInstance,
    pub resolve_host_contract_interface: unsafe extern "C" fn(
        this: *const HostApi,
        contract_id: u64,
        min_version: u32,
    ) -> *const HostContractInterface,
    pub list_bundles: unsafe extern "C" fn(this: *const HostApi) -> Array<BundleId>,
    pub get_dependencies: unsafe extern "C" fn(this: *const HostApi) -> Array<DependencyInfo>,
    pub load_bundle: unsafe extern "C" fn(
        this: *const HostApi, path: *const u8, path_len: usize,
    ) -> AbiError,
    pub reload_bundle: unsafe extern "C" fn(
        this: *const HostApi, path: *const u8, path_len: usize,
    ) -> AbiError,
    pub register_host_contract: unsafe extern "C" fn(
        this: *const HostApi,
        interface: *const HostContractInterface,
    ) -> AbiError,
    pub register_loader: unsafe extern "C" fn(
        this: *const HostApi, loader_ptr: *mut c_void,
    ) -> AbiError,
    pub get_last_error: unsafe extern "C" fn(
        this: *const HostApi, buf: *mut u8, buf_len: usize,
    ) -> usize,
    pub get_error_len: unsafe extern "C" fn(this: *const HostApi) -> usize,
    pub unload_bundle: unsafe extern "C" fn(
        this: *const HostApi, bundle_id: BundleId,
    ) -> AbiError,
    pub log: unsafe extern "C" fn(
        this: *const HostApi,
        level: u32,
        scope: StringView,
        message: StringView,
    ),
    pub reserved: *const core::ffi::c_void, // always null; consumers must not read it
}

All functions use the self-passing pattern — the first parameter is always the HostApi pointer itself. Call (iface.register_guest_contract)(host, &MY_DESCRIPTOR, &MY_VTABLE) for each contract your plugin implements.


GuestContractHandle

Opaque handle to another loaded plugin. Validated by the host on every use.

#[repr(C)]
pub struct GuestContractHandle {
    pub index:      u32,
    pub generation: u32,
}

A zeroed GuestContractHandle ({ index: 0, generation: 0 }) is the sentinel "not found" value.


BundleInitContext

Passed to polyplug_init as the second argument. Contains bundle_id (the FNV-1a hash of the bundle name) and bundle_path — the absolute path to the bundle directory on disk (24 bytes total).

Do not store the raw pointer. Copy the string value if you need it after init.


FnPtr

Wrapper around *const () that implements Sync and Send, enabling function pointers in static arrays.

// Build your vtable function array:
static MY_FNS: [FnPtr; 2] = [
    FnPtr(my_first_fn  as *const ()),
    FnPtr(my_second_fn as *const ()),
];

GuestError

High-level error type for use in generated trait implementations.

pub struct GuestError {
    pub code:    AbiErrorCode,
    pub message: String,
}

Implements std::error::Error and Display. Returned from generated wrapper code when an ABI call returns a non-zero code.


ABI Error Codes

The AbiErrorCode enum defines all possible return codes:

#[repr(u32)]
pub enum AbiErrorCode {
    Ok = 0,                    // Success
    Generic = 1,               // Unclassified error
    BufferTooSmall = 2,        // Output buffer too small
    Panic = 3,                 // Plugin panicked
    NotFound = 4,              // Plugin or contract not found
    StaleHandle = 5,           // Handle refers to unloaded plugin
    FunctionNotAvailable = 6,  // Function index out of range
    DuplicateProvider = 7,     // Contract already registered
    InvalidPointer = 8,        // Null or invalid pointer
    ReentrantCall = 9,         // Cross-call would re-enter a VM already dispatching
    // Host contract errors (100+):
    HostContractNotFound = 100,
    HostContractVersionMismatch = 101,
    HostContractCallFailed = 102,
}

Use AbiErrorCode::Ok for success, AbiErrorCode::Generic for errors.


Hash Utilities

ID hashing lives in the polyplug_utils crate. Guest contract IDs are FNV-1a 64-bit hashes of "guest_contract:name@major_version" (the prefix keeps guest and host contract IDs from colliding). polyplugc generates the constants for you; use guest_contract_id() at runtime to verify.

use polyplug_utils::guest_contract_id;

// Verify the compile-time constant matches the runtime hash:
assert_eq!(guest_contract_id("my.contract", 1), MY_CONTRACT_ID);
Function Description
guest_contract_id(name: &str, major_version: u32) -> u64 FNV-1a of "guest_contract:name@major"
host_contract_id(name: &str, major_version: u32) -> u64 FNV-1a of "host_contract:name@major"
bundle_id(name: &str) -> u64 FNV-1a of the bundle name
fnv1a_64(data: &[u8]) -> u64 Raw FNV-1a 64-bit hash

Allocator API

All memory that crosses the plugin/host boundary must use the host allocator, reached through the alloc / free function-pointer fields of the HostApi your implementation received via its HostContext. There are no polyplug_host_alloc / polyplug_host_free C exports — allocation always flows through that interface. Never return heap-allocated data from your Rust allocator — the host cannot free it.

For strings, prefer the HostContext::alloc_string method, which allocates through host.alloc:

// `self.host` is the HostContext your factory received and stored.
let sv: StringView = self.host.alloc_string("hello")?;
// sv.ptr points to host-allocated memory; the host frees it via
// host.free(host, sv.ptr, sv.len, 1) when done.

For raw buffers, call the interface directly through the context's raw pointer:

let host: *const HostApi = self.host.as_ptr();
// SAFETY: the HostContext came from create_instance and is valid for the
// plugin's lifetime.
let ptr: *mut u8 = unsafe { ((*host).alloc)(host, 64, 8) };
if ptr.is_null() {
    // allocation failed
}
// Free it through the same interface:
unsafe { ((*host).free)(host, ptr, 64, 8) };

Rules:

  • A plugin must never free memory it did not allocate.
  • Never place cross-boundary data on the Rust managed heap (Box, Vec, String).
  • For string outputs, allocate via HostContext::alloc_string (or host.alloc), copy bytes in, return a StringView pointing to that allocation.

Implementing a Contract — Step by Step

Step 1: Compute the contract ID

polyplugc generates the constant for you. For hand-written plugins:

// FNV-1a-64 of "guest_contract:pipeline.transformer@1"
const TRANSFORMER_CONTRACT_ID: u64 = 0x1F7F345779EDC6DC;

Verify at startup with polyplug_utils::guest_contract_id("pipeline.transformer", 1).

Step 2: Define your ABI types

Declare #[repr(C)] structs matching the contract's argument and return types exactly.

#[repr(C)]
pub struct DataRecord {
    pub name:  StringView,
    pub value: StringView,
    pub count: u32,
}

Step 3: Implement your function with the generic dispatch signature

Every function in the vtable has the signature extern "C" fn(*const (), *mut ()) -> AbiError. Cast the opaque pointers to your concrete types inside.

extern "C" fn plugin_transform(args: *const (), out: *mut ()) -> AbiError {
    // SAFETY: host guarantees args → DataRecord and out → DataRecord per ABI contract.
    unsafe { do_transform(args as *const DataRecord, out as *mut DataRecord) }
}

Step 4: Declare static vtable and descriptor

static TRANSFORM_FNS: [FnPtr; 1] = [FnPtr(plugin_transform as *const ())];

static TRANSFORM_VTABLE: GuestContractInterface = GuestContractInterface {
    contract_id:      GuestContractId::from_u64(TRANSFORMER_CONTRACT_ID),
    contract_version: Version { major: 1, minor: 0, patch: 0 },
    dispatch_type:    DispatchType::Native,
    create_instance:  my_create_instance,
    destroy_instance: my_destroy_instance,
    dispatch:         DispatchMechanisms {
        native: NativeDispatch {
            function_count: 1,
            functions:      TRANSFORM_FNS.as_ptr() as *const *const (),
        },
    },
};

static TRANSFORM_DESCRIPTOR: PluginDescriptor = PluginDescriptor {
    name:          StringView { ptr: b"my_transformer".as_ptr(), len: 14 },
    contract_name: StringView { ptr: b"pipeline.transformer".as_ptr(), len: 20 },
    version:       Version { major: 1, minor: 0, patch: 0 },
};

Step 5: Export the two mandatory ABI symbols

#[unsafe(no_mangle)]
pub extern "C" fn polyplug_abi_version() -> u32 {
    POLYPLUG_ABI_VERSION
}

#[unsafe(no_mangle)]
pub unsafe extern "C" fn polyplug_init(
    host: *const HostApi,
    _ctx: *const BundleInitContext,
) -> AbiError {
    if host.is_null() {
        return AbiError { code: AbiErrorCode::Generic as u32, message: StringView::null() };
    }
    // SAFETY: host is non-null and provided by the host per ABI contract.
    let iface: &HostApi = unsafe { &*host };
    // SAFETY: register_guest_contract is a valid function pointer. TRANSFORM_DESCRIPTOR and
    // TRANSFORM_VTABLE are 'static.
    unsafe {
        (iface.register_guest_contract)(host, &TRANSFORM_DESCRIPTOR, &TRANSFORM_VTABLE)
    }
}

Full Example

The canonical Rust guest plugins live under examples/guests/rust/decoder, transformer, validator, encoder, and reporter. For instance, examples/guests/rust/transformer/src/lib.rs implements the data.Transformer contract by implementing the polyplugc-generated trait and registering it through the generated polyplug_init.

Key points demonstrated:

  • Generated bindings: polyplugc generate emits generated/guest/ (trait, vtable, polyplug_init) and generated/manifest.toml
  • Safe UTF-8 decoding of StringView data via polyplug_guest::to_str
  • Host-allocator string returns via polyplug_guest::HostContext::alloc_string
  • Proper error returns (Result<_, GuestError>) instead of panics
  • // SAFETY: comments on every unsafe block
examples/guests/rust/transformer/
├── Cargo.toml        # crate-type = ["cdylib"]; polyplug_abi + polyplug_guest + polyplug_utils deps
├── bundle.toml       # contract definition consumed by polyplugc
├── generated/        # polyplugc output: guest bindings + manifest.toml — do not edit
└── src/
    └── lib.rs        # the hand-written part: the trait implementation

manifest.toml — Declaring Your Bundle

Every plugin bundle needs a manifest.toml alongside the compiled .so. polyplugc generate emits it for you (under generated/); a hand-written one looks like this:

name     = "my_plugin"
id       = 6083502456968126439   # fnv1a_64("my_plugin"); polyplugc precomputes this
version  = "1.0.0"
runtime  = "native"
provides = ["pipeline.transformer@1"]
function_count = { "pipeline.transformer@1" = 1 }
file     = "libmy_plugin.so"

file may also be a per-platform table (this is what polyplugc emits):

[file]
linux.x86_64 = "libmy_plugin.so"
Field Description
name Unique name for this bundle
id Bundle ID — required, non-zero; polyplugc precomputes it from the name
version Bundle version string
runtime Must be "native" for Rust/C/C++ plugins (required)
file Filename of the compiled shared library, or a per-platform table
provides Contract names this bundle implements, at "name@major"
function_count Map from "name@major" to number of exported functions
[[dependency]] Optional declared dependencies (see docs/TRUST_MODEL.md)

Building

# Debug build
cargo build

# Release build (recommended for distribution)
cargo build --release

# Copy to your bundle directory
cp target/release/libmy_plugin.so /path/to/bundle/

The output is a native shared library. File names by platform:

Platform Output file
Linux libmy_plugin.so
macOS libmy_plugin.dylib
Windows my_plugin.dll

Place the compiled library alongside its manifest.toml in your bundle directory.


Bundle layout

Assemble the bundle directory yourself:

dist/my-plugin/
├── manifest.toml          # emitted by `generate` (carries the precomputed bundle_id)
└── libmy_plugin.so        # the cdylib you compiled (.dylib on macOS, .dll on Windows; loader = "native")

Validate the assembled directory before shipping:

polyplugc validate --bundle-dir dist/my-plugin/

Error Handling

Never use .unwrap() in plugin code. Return AbiError with a non-zero code instead.

// FORBIDDEN in plugin code
let s = std::str::from_utf8(bytes).unwrap();

// CORRECT — return an AbiError on failure
let s: &str = match std::str::from_utf8(bytes) {
    Ok(s) => s,
    Err(_) => {
        return AbiError {
            code: AbiErrorCode::Generic as u32,
            message: StringView {
                ptr: b"invalid UTF-8".as_ptr(),
                len: 13,
            },
        };
    }
};

AbiError.message is always a static or runtime-owned string. The receiver never frees it — a bare StringView carries no allocation provenance, so the host cannot and does not free message.ptr. Point it at a 'static byte string, as above. If you need rich, allocated error detail, expose it through get_last_error rather than through AbiError.message.

AbiError.code is a raw u32, not the AbiErrorCode enum: plugins are untrusted and may return any 32-bit value. Construct it with AbiErrorCode::Variant as u32 and interpret a received code with AbiErrorCode::from_u32.


Memory Rules

Rule Detail
Cross-boundary allocations Must use the HostApi alloc / free fields (via HostContext)
Plugin-side allocations May use Rust allocator, but must NOT cross the boundary
Returned strings / buffers Must be allocated via HostContext::alloc_string / host.alloc
StringView (input) Borrowed — do NOT free; valid only for the duration of the call
Buffer (output) Owned — caller frees with host.free(host, ptr, cap, align)
AbiError.message (output) Static or runtime-owned; receiver NEVER frees it

More Examples

  • examples/guests/rust/ — Rust guest plugins (decoder, transformer, validator, encoder, reporter)
  • examples/guests/ — The same guest plugins in C++, C#, Python, Lua, and JavaScript
  • examples/hosts/ — Host runtimes that load polyplug bundles
  • examples/api.toml — API definition used by polyplugc for codegen