Skip to main content

nautilus_plugin/
boundary.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Primitive `#[repr(C)]` types used at the plug-in boundary.
17//!
18//! Only types in this module (and other `#[repr(C)]` types built from them) may
19//! cross between an independently compiled plug-in cdylib and the host. Standard
20//! library types like `String`, `Vec`, and `Box<dyn Trait>` rely on Rust's
21//! unstable ABI and must never appear in a function signature exposed across
22//! the boundary.
23
24#![allow(unsafe_code)]
25
26use core::{marker::PhantomData, ptr, slice};
27
28/// A borrowed UTF-8 string with a lifetime tied to the producer's storage.
29///
30/// Use this for `'static` strings baked into a plug-in's manifest (type names,
31/// version strings). The host reads through the pointer while the producing
32/// library is loaded; in v1 that is the process lifetime, since plug-ins are
33/// not unloaded.
34#[repr(C)]
35#[derive(Clone, Copy)]
36pub struct BorrowedStr<'a> {
37    pub ptr: *const u8,
38    pub len: usize,
39    _phantom: PhantomData<&'a [u8]>,
40}
41
42/// SAFETY: `BorrowedStr` is just a pointer + length; sending it across threads
43/// is sound as long as the underlying storage outlives the use. In v1 the
44/// storage is process-lifetime static memory in the producing library.
45unsafe impl Send for BorrowedStr<'_> {}
46/// SAFETY: see `Send` impl.
47unsafe impl Sync for BorrowedStr<'_> {}
48
49impl<'a> BorrowedStr<'a> {
50    /// Returns an empty borrowed string.
51    #[must_use]
52    pub const fn empty() -> Self {
53        Self {
54            ptr: ptr::null(),
55            len: 0,
56            _phantom: PhantomData,
57        }
58    }
59
60    /// Wraps a Rust string slice as a borrowed boundary string.
61    #[must_use]
62    pub const fn from_str(s: &'a str) -> Self {
63        Self {
64            ptr: s.as_ptr(),
65            len: s.len(),
66            _phantom: PhantomData,
67        }
68    }
69
70    /// Converts the borrowed string back to a `&str`.
71    ///
72    /// # Safety
73    ///
74    /// The caller must ensure the producing storage is still live and the
75    /// bytes are valid UTF-8.
76    #[must_use]
77    pub unsafe fn as_str(&self) -> &'a str {
78        if self.ptr.is_null() || self.len == 0 {
79            return "";
80        }
81        // SAFETY: caller upholds the lifetime and UTF-8 contract.
82        let bytes = unsafe { slice::from_raw_parts(self.ptr, self.len) };
83        // SAFETY: producer commits to valid UTF-8.
84        unsafe { core::str::from_utf8_unchecked(bytes) }
85    }
86
87    /// Converts the borrowed string to a `&str`, validating UTF-8.
88    ///
89    /// Use this at trust boundaries where the producer's UTF-8 commitment
90    /// should be verified rather than assumed.
91    ///
92    /// # Errors
93    ///
94    /// Returns an error when the bytes are not valid UTF-8.
95    ///
96    /// # Safety
97    ///
98    /// The caller must ensure the producing storage is still live.
99    pub unsafe fn try_as_str(&self) -> Result<&'a str, core::str::Utf8Error> {
100        if self.ptr.is_null() || self.len == 0 {
101            return Ok("");
102        }
103        // SAFETY: caller upholds the lifetime contract.
104        let bytes = unsafe { slice::from_raw_parts(self.ptr, self.len) };
105        core::str::from_utf8(bytes)
106    }
107
108    /// Converts the borrowed string to an owned `String`, replacing invalid
109    /// UTF-8 sequences with the replacement character.
110    ///
111    /// # Safety
112    ///
113    /// The caller must ensure the producing storage is still live.
114    #[must_use]
115    pub unsafe fn to_string_lossy(&self) -> String {
116        if self.ptr.is_null() || self.len == 0 {
117            return String::new();
118        }
119        // SAFETY: caller upholds the lifetime contract.
120        let bytes = unsafe { slice::from_raw_parts(self.ptr, self.len) };
121        String::from_utf8_lossy(bytes).into_owned()
122    }
123}
124
125impl core::fmt::Debug for BorrowedStr<'_> {
126    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
127        // SAFETY: Debug is best-effort; if the producer has dropped storage
128        // this would be UB. The plug-in contract pins manifest strings to
129        // process lifetime so reads here are sound. Lossy decoding keeps the
130        // impl sound for producers that violate the UTF-8 contract.
131        let s = unsafe { self.to_string_lossy() };
132        write!(f, "BorrowedStr({s:?})")
133    }
134}
135
136/// A borrowed slice of `T` with a lifetime tied to the producer's storage.
137///
138/// Used in the manifest to enumerate per-trait registration entries without
139/// crossing the boundary with `Vec`.
140#[repr(C)]
141#[derive(Clone, Copy)]
142pub struct Slice<'a, T> {
143    pub ptr: *const T,
144    pub len: usize,
145    _phantom: PhantomData<&'a [T]>,
146}
147
148/// SAFETY: see [`BorrowedStr`].
149unsafe impl<T: Sync> Send for Slice<'_, T> {}
150/// SAFETY: see [`BorrowedStr`].
151unsafe impl<T: Sync> Sync for Slice<'_, T> {}
152
153impl<'a, T> Slice<'a, T> {
154    /// Returns an empty slice.
155    #[must_use]
156    pub const fn empty() -> Self {
157        Self {
158            ptr: ptr::null(),
159            len: 0,
160            _phantom: PhantomData,
161        }
162    }
163
164    /// Wraps a Rust slice as a boundary slice.
165    #[must_use]
166    pub const fn from_slice(s: &'a [T]) -> Self {
167        Self {
168            ptr: s.as_ptr(),
169            len: s.len(),
170            _phantom: PhantomData,
171        }
172    }
173
174    /// Borrows the slice as a `&[T]`.
175    ///
176    /// # Safety
177    ///
178    /// The caller must ensure the producing storage is still live.
179    #[must_use]
180    pub unsafe fn as_slice(&self) -> &'a [T] {
181        if self.ptr.is_null() || self.len == 0 {
182            return &[];
183        }
184        // SAFETY: caller upholds the lifetime.
185        unsafe { slice::from_raw_parts(self.ptr, self.len) }
186    }
187}
188
189/// Coarse-grained error categories for [`PluginError`].
190///
191/// Encoded as `u32` for stable wire representation.
192#[repr(u32)]
193#[derive(Clone, Copy, Debug, PartialEq, Eq)]
194pub enum PluginErrorCode {
195    Ok = 0,
196    Generic = 1,
197    Panic = 2,
198    InvalidArgument = 3,
199    NotImplemented = 4,
200    AbiMismatch = 5,
201    SerializationFailed = 6,
202}
203
204/// An owned byte buffer crossing the plug-in boundary.
205///
206/// Allocated by the producing side and freed by the producer's `drop_fn` so
207/// allocator mismatches between host and plug-in stay impossible. v1 uses
208/// this only for runtime-constructed error messages; data payloads cross via
209/// other paths (Arrow IPC for batches, JSON via `OwnedBytes` for single items).
210#[repr(C)]
211pub struct OwnedBytes {
212    pub ptr: *mut u8,
213    pub len: usize,
214    pub cap: usize,
215    pub drop_fn: Option<unsafe extern "C" fn(ptr: *mut u8, len: usize, cap: usize)>,
216}
217
218/// SAFETY: a heap pointer freed only by its producer's `drop_fn`; safe to
219/// transfer ownership across threads.
220unsafe impl Send for OwnedBytes {}
221
222impl OwnedBytes {
223    /// Constructs an empty `OwnedBytes` with no drop function.
224    #[must_use]
225    pub const fn empty() -> Self {
226        Self {
227            ptr: ptr::null_mut(),
228            len: 0,
229            cap: 0,
230            drop_fn: None,
231        }
232    }
233
234    /// Returns whether the buffer is empty (no allocation).
235    #[must_use]
236    pub fn is_empty(&self) -> bool {
237        self.len == 0 || self.ptr.is_null()
238    }
239
240    /// Constructs an `OwnedBytes` from a Rust `Vec<u8>` using the producer's
241    /// allocator and stamps the matching producer-side free as `drop_fn`.
242    ///
243    /// Consumers release the buffer by dropping the `OwnedBytes` (which
244    /// invokes the embedded `drop_fn`) or by calling that `drop_fn`
245    /// explicitly. Do not call [`drop_owned_bytes`] on a value received
246    /// across the plug-in boundary: that would free with the *consumer's*
247    /// allocator, which may not match the producer's. [`drop_owned_bytes`]
248    /// is only the default function installed here for the producer; each
249    /// side sees its own copy linked against its own allocator.
250    #[must_use]
251    pub fn from_vec(v: Vec<u8>) -> Self {
252        let mut v = core::mem::ManuallyDrop::new(v);
253        let ptr = v.as_mut_ptr();
254        let len = v.len();
255        let cap = v.capacity();
256        Self {
257            ptr,
258            len,
259            cap,
260            drop_fn: Some(drop_owned_bytes),
261        }
262    }
263
264    /// Borrows the buffer as a byte slice.
265    ///
266    /// # Safety
267    ///
268    /// The buffer must still be live (i.e. its `drop_fn` not yet called).
269    #[must_use]
270    pub unsafe fn as_bytes(&self) -> &[u8] {
271        if self.is_empty() {
272            return &[];
273        }
274        // SAFETY: caller upholds liveness.
275        unsafe { slice::from_raw_parts(self.ptr, self.len) }
276    }
277}
278
279impl Drop for OwnedBytes {
280    fn drop(&mut self) {
281        if let Some(f) = self.drop_fn.take()
282            && !self.ptr.is_null()
283        {
284            // SAFETY: ptr/len/cap originate from `from_vec` or from a
285            // matching producer; drop_fn is the matching free.
286            unsafe { f(self.ptr, self.len, self.cap) };
287            self.ptr = ptr::null_mut();
288            self.len = 0;
289            self.cap = 0;
290        }
291    }
292}
293
294/// Default `drop_fn` used by [`OwnedBytes::from_vec`]. Plug-ins that build
295/// `OwnedBytes` via `from_vec` get matching free behaviour automatically.
296///
297/// # Safety
298///
299/// The caller must pass `ptr`, `len`, and `cap` originally returned by a
300/// `Vec<u8>` that was leaked via `from_vec`.
301pub unsafe extern "C" fn drop_owned_bytes(ptr: *mut u8, len: usize, cap: usize) {
302    if ptr.is_null() {
303        return;
304    }
305    // SAFETY: pointer originates from `Vec::into_raw_parts`-style leak.
306    unsafe {
307        let _ = Vec::from_raw_parts(ptr, len, cap);
308    }
309}
310
311/// Generic plug-in error returned across the boundary.
312///
313/// `message` is owned by the producer; the consumer drops it via its
314/// `OwnedBytes` `drop_fn` once it has been logged or wrapped.
315#[repr(C)]
316pub struct PluginError {
317    pub code: PluginErrorCode,
318    pub message: OwnedBytes,
319}
320
321impl PluginError {
322    /// Constructs an error with a `Generic` code and a message string.
323    #[must_use]
324    pub fn generic(message: impl AsRef<str>) -> Self {
325        Self {
326            code: PluginErrorCode::Generic,
327            message: OwnedBytes::from_vec(message.as_ref().as_bytes().to_vec()),
328        }
329    }
330
331    /// Constructs an error with the given code and message string.
332    #[must_use]
333    pub fn new(code: PluginErrorCode, message: impl AsRef<str>) -> Self {
334        Self {
335            code,
336            message: OwnedBytes::from_vec(message.as_ref().as_bytes().to_vec()),
337        }
338    }
339
340    /// Constructs a panic error with the given message.
341    #[must_use]
342    pub fn panic(message: impl AsRef<str>) -> Self {
343        Self::new(PluginErrorCode::Panic, message)
344    }
345
346    /// Returns the message as a `String` (lossy if non-UTF8).
347    #[must_use]
348    pub fn message_string(&self) -> String {
349        // SAFETY: message is live until self is dropped.
350        let bytes = unsafe { self.message.as_bytes() };
351        String::from_utf8_lossy(bytes).into_owned()
352    }
353}
354
355impl core::fmt::Debug for PluginError {
356    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
357        f.debug_struct(stringify!(PluginError))
358            .field("code", &self.code)
359            .field("message", &self.message_string())
360            .finish()
361    }
362}
363
364/// A `Result`-shaped union for boundary calls.
365///
366/// `#[repr(C, u8)]` so the discriminant is a single byte at offset zero,
367/// independent of payload alignment.
368#[repr(C, u8)]
369pub enum PluginResult<T> {
370    Ok(T),
371    Err(PluginError),
372}
373
374impl<T> PluginResult<T> {
375    /// Converts to a `core::result::Result`, dropping the discriminant.
376    ///
377    /// # Errors
378    ///
379    /// Returns the contained [`PluginError`] when the boundary result is `Err`.
380    pub fn into_result(self) -> Result<T, PluginError> {
381        match self {
382            Self::Ok(t) => Ok(t),
383            Self::Err(e) => Err(e),
384        }
385    }
386
387    /// Wraps a `Result` produced inside Rust into a boundary result.
388    pub fn from_result(r: Result<T, PluginError>) -> Self {
389        match r {
390            Ok(t) => Self::Ok(t),
391            Err(e) => Self::Err(e),
392        }
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use std::sync::atomic::{AtomicUsize, Ordering};
399
400    use rstest::rstest;
401
402    use super::*;
403
404    #[rstest]
405    #[case::ascii("hello")]
406    #[case::empty("")]
407    #[case::utf8("héllo wörld")]
408    #[case::multibyte("\u{1F600}\u{1F4A9}")]
409    fn borrowed_str_round_trips(#[case] s: &str) {
410        let b = BorrowedStr::from_str(s);
411        // SAFETY: storage lives for the duration of this test.
412        let back = unsafe { b.as_str() };
413        assert_eq!(back, s);
414    }
415
416    #[rstest]
417    fn slice_round_trips() {
418        let data: [u32; 3] = [1, 2, 3];
419        let s = Slice::from_slice(&data);
420        // SAFETY: storage lives for the duration of this test.
421        let back = unsafe { s.as_slice() };
422        assert_eq!(back, &[1u32, 2, 3]);
423    }
424
425    #[rstest]
426    fn empty_slice_returns_empty() {
427        let s: Slice<u8> = Slice::empty();
428        // SAFETY: empty slice is always safe to view.
429        let back = unsafe { s.as_slice() };
430        assert!(back.is_empty());
431    }
432
433    #[rstest]
434    fn owned_bytes_round_trip_and_drop() {
435        let payload = b"hello world".to_vec();
436        let owned = OwnedBytes::from_vec(payload.clone());
437        // SAFETY: still live until owned drops.
438        let view = unsafe { owned.as_bytes() }.to_vec();
439        assert_eq!(view, payload);
440        drop(owned);
441    }
442
443    #[rstest]
444    fn owned_bytes_drop_fn_runs_exactly_once() {
445        static COUNTER: AtomicUsize = AtomicUsize::new(0);
446        unsafe extern "C" fn counting_drop(ptr: *mut u8, len: usize, cap: usize) {
447            if !ptr.is_null() {
448                COUNTER.fetch_add(1, Ordering::SeqCst);
449                // SAFETY: pointer originates from the boxed slice leaked below.
450                unsafe {
451                    let _ = Vec::from_raw_parts(ptr, len, cap);
452                }
453            }
454        }
455
456        COUNTER.store(0, Ordering::SeqCst);
457        let mut v = core::mem::ManuallyDrop::new(vec![1u8, 2, 3, 4]);
458        let ptr = v.as_mut_ptr();
459        let len = v.len();
460        let cap = v.capacity();
461        let owned = OwnedBytes {
462            ptr,
463            len,
464            cap,
465            drop_fn: Some(counting_drop),
466        };
467        assert_eq!(COUNTER.load(Ordering::SeqCst), 0);
468        drop(owned);
469        assert_eq!(COUNTER.load(Ordering::SeqCst), 1);
470    }
471
472    #[rstest]
473    fn plugin_error_carries_message() {
474        let err = PluginError::generic("bad input");
475        assert_eq!(err.code, PluginErrorCode::Generic);
476        assert_eq!(err.message_string(), "bad input");
477    }
478
479    #[rstest]
480    fn plugin_result_round_trips() {
481        let ok: PluginResult<u32> = PluginResult::Ok(42);
482        let r = ok.into_result();
483        assert_eq!(r.unwrap(), 42);
484
485        let err: PluginResult<u32> = PluginResult::Err(PluginError::generic("nope"));
486        let r = err.into_result();
487        assert!(r.is_err());
488    }
489
490    #[rstest]
491    fn plugin_result_from_result_round_trips() {
492        let r: PluginResult<u32> = PluginResult::from_result(Ok(7));
493        assert_eq!(r.into_result().unwrap(), 7);
494
495        let r: PluginResult<u32> = PluginResult::from_result(Err(PluginError::generic("x")));
496        let e = r.into_result().unwrap_err();
497        assert_eq!(e.code, PluginErrorCode::Generic);
498        assert_eq!(e.message_string(), "x");
499    }
500
501    #[rstest]
502    fn borrowed_str_empty_is_empty_when_borrowed_back() {
503        let b = BorrowedStr::empty();
504        assert!(b.ptr.is_null());
505        assert_eq!(b.len, 0);
506        // SAFETY: an empty BorrowedStr returns "" without dereferencing.
507        assert_eq!(unsafe { b.as_str() }, "");
508    }
509
510    #[rstest]
511    fn borrowed_str_debug_prints_contents() {
512        let b = BorrowedStr::from_str("hello");
513        let rendered = format!("{b:?}");
514        assert!(rendered.contains("hello"));
515    }
516
517    #[rstest]
518    fn slice_empty_descriptor_is_null_and_zero_len() {
519        let s: Slice<u32> = Slice::empty();
520        assert!(s.ptr.is_null());
521        assert_eq!(s.len, 0);
522    }
523
524    #[rstest]
525    fn owned_bytes_empty_is_empty() {
526        let owned = OwnedBytes::empty();
527        assert!(owned.is_empty());
528        assert!(owned.ptr.is_null());
529        assert_eq!(owned.len, 0);
530        assert_eq!(owned.cap, 0);
531        assert!(owned.drop_fn.is_none());
532        // SAFETY: empty OwnedBytes borrows as &[] without dereferencing.
533        assert!(unsafe { owned.as_bytes() }.is_empty());
534    }
535
536    #[rstest]
537    fn owned_bytes_is_empty_for_zero_length_buffer() {
538        let owned = OwnedBytes::from_vec(Vec::new());
539        assert!(owned.is_empty());
540        drop(owned);
541    }
542
543    #[rstest]
544    fn owned_bytes_drop_with_null_ptr_short_circuits() {
545        let owned = OwnedBytes {
546            ptr: ptr::null_mut(),
547            len: 0,
548            cap: 0,
549            drop_fn: Some(drop_owned_bytes),
550        };
551        // Should not panic or attempt to free a null pointer.
552        drop(owned);
553    }
554
555    #[rstest]
556    fn drop_owned_bytes_handles_null_ptr_without_panic() {
557        // SAFETY: documented contract: null pointer short-circuits.
558        unsafe {
559            drop_owned_bytes(ptr::null_mut(), 0, 0);
560        };
561    }
562
563    #[rstest]
564    fn drop_owned_bytes_frees_vec_leaked_with_from_vec_layout() {
565        let mut v = core::mem::ManuallyDrop::new(vec![1u8, 2, 3, 4, 5]);
566        let ptr = v.as_mut_ptr();
567        let len = v.len();
568        let cap = v.capacity();
569        // SAFETY: pointer/len/cap originate from a `Vec<u8>` leaked above;
570        // `drop_owned_bytes` reconstructs and drops it with the matching
571        // layout.
572        unsafe {
573            drop_owned_bytes(ptr, len, cap);
574        };
575    }
576
577    #[rstest]
578    fn plugin_error_new_carries_code_and_message() {
579        let err = PluginError::new(PluginErrorCode::InvalidArgument, "bad arg");
580        assert_eq!(err.code, PluginErrorCode::InvalidArgument);
581        assert_eq!(err.message_string(), "bad arg");
582    }
583
584    #[rstest]
585    fn plugin_error_panic_sets_panic_code() {
586        let err = PluginError::panic("oops");
587        assert_eq!(err.code, PluginErrorCode::Panic);
588        assert_eq!(err.message_string(), "oops");
589    }
590
591    #[rstest]
592    fn plugin_error_debug_renders_code_and_message() {
593        let err = PluginError::new(PluginErrorCode::NotImplemented, "todo");
594        let rendered = format!("{err:?}");
595        assert!(rendered.contains("NotImplemented"));
596        assert!(rendered.contains("todo"));
597    }
598
599    #[rstest]
600    #[case::ok(PluginErrorCode::Ok, 0u32)]
601    #[case::generic(PluginErrorCode::Generic, 1)]
602    #[case::panic(PluginErrorCode::Panic, 2)]
603    #[case::invalid_argument(PluginErrorCode::InvalidArgument, 3)]
604    #[case::not_implemented(PluginErrorCode::NotImplemented, 4)]
605    #[case::abi_mismatch(PluginErrorCode::AbiMismatch, 5)]
606    #[case::serialization_failed(PluginErrorCode::SerializationFailed, 6)]
607    fn plugin_error_code_has_stable_discriminant(
608        #[case] code: PluginErrorCode,
609        #[case] expected: u32,
610    ) {
611        assert_eq!(code as u32, expected);
612    }
613}