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 borrowed_str_try_as_str_rejects_invalid_utf8() {
418        static INVALID_UTF8: [u8; 1] = [0xFF];
419        let mut b = BorrowedStr::empty();
420        b.ptr = INVALID_UTF8.as_ptr();
421        b.len = INVALID_UTF8.len();
422
423        // SAFETY: storage lives for the duration of this test.
424        let result = unsafe { b.try_as_str() };
425
426        assert!(result.is_err());
427    }
428
429    #[rstest]
430    fn borrowed_str_to_string_lossy_replaces_invalid_utf8() {
431        static INVALID_UTF8: [u8; 1] = [0xFF];
432        let mut b = BorrowedStr::empty();
433        b.ptr = INVALID_UTF8.as_ptr();
434        b.len = INVALID_UTF8.len();
435
436        // SAFETY: storage lives for the duration of this test.
437        let rendered = unsafe { b.to_string_lossy() };
438
439        assert_eq!(rendered, "\u{FFFD}");
440    }
441
442    #[rstest]
443    fn slice_round_trips() {
444        let data: [u32; 3] = [1, 2, 3];
445        let s = Slice::from_slice(&data);
446        // SAFETY: storage lives for the duration of this test.
447        let back = unsafe { s.as_slice() };
448        assert_eq!(back, &[1u32, 2, 3]);
449    }
450
451    #[rstest]
452    fn empty_slice_returns_empty() {
453        let s: Slice<u8> = Slice::empty();
454        // SAFETY: empty slice is always safe to view.
455        let back = unsafe { s.as_slice() };
456        assert!(back.is_empty());
457    }
458
459    #[rstest]
460    fn owned_bytes_round_trip_and_drop() {
461        let payload = b"hello world".to_vec();
462        let owned = OwnedBytes::from_vec(payload.clone());
463        // SAFETY: still live until owned drops.
464        let view = unsafe { owned.as_bytes() }.to_vec();
465        assert_eq!(view, payload);
466        drop(owned);
467    }
468
469    #[rstest]
470    fn owned_bytes_drop_fn_runs_exactly_once() {
471        static COUNTER: AtomicUsize = AtomicUsize::new(0);
472        unsafe extern "C" fn counting_drop(ptr: *mut u8, len: usize, cap: usize) {
473            if !ptr.is_null() {
474                COUNTER.fetch_add(1, Ordering::SeqCst);
475                // SAFETY: pointer originates from the boxed slice leaked below.
476                unsafe {
477                    let _ = Vec::from_raw_parts(ptr, len, cap);
478                }
479            }
480        }
481
482        COUNTER.store(0, Ordering::SeqCst);
483        let mut v = core::mem::ManuallyDrop::new(vec![1u8, 2, 3, 4]);
484        let ptr = v.as_mut_ptr();
485        let len = v.len();
486        let cap = v.capacity();
487        let owned = OwnedBytes {
488            ptr,
489            len,
490            cap,
491            drop_fn: Some(counting_drop),
492        };
493        assert_eq!(COUNTER.load(Ordering::SeqCst), 0);
494        drop(owned);
495        assert_eq!(COUNTER.load(Ordering::SeqCst), 1);
496    }
497
498    #[rstest]
499    fn plugin_error_carries_message() {
500        let err = PluginError::generic("bad input");
501        assert_eq!(err.code, PluginErrorCode::Generic);
502        assert_eq!(err.message_string(), "bad input");
503    }
504
505    #[rstest]
506    fn plugin_result_round_trips() {
507        let ok: PluginResult<u32> = PluginResult::Ok(42);
508        let r = ok.into_result();
509        assert_eq!(r.unwrap(), 42);
510
511        let err: PluginResult<u32> = PluginResult::Err(PluginError::generic("nope"));
512        let r = err.into_result();
513        assert!(r.is_err());
514    }
515
516    #[rstest]
517    fn plugin_result_from_result_round_trips() {
518        let r: PluginResult<u32> = PluginResult::from_result(Ok(7));
519        assert_eq!(r.into_result().unwrap(), 7);
520
521        let r: PluginResult<u32> = PluginResult::from_result(Err(PluginError::generic("x")));
522        let e = r.into_result().unwrap_err();
523        assert_eq!(e.code, PluginErrorCode::Generic);
524        assert_eq!(e.message_string(), "x");
525    }
526
527    #[rstest]
528    fn borrowed_str_empty_is_empty_when_borrowed_back() {
529        let b = BorrowedStr::empty();
530        assert!(b.ptr.is_null());
531        assert_eq!(b.len, 0);
532        // SAFETY: an empty BorrowedStr returns "" without dereferencing.
533        assert_eq!(unsafe { b.as_str() }, "");
534    }
535
536    #[rstest]
537    fn borrowed_str_debug_prints_contents() {
538        let b = BorrowedStr::from_str("hello");
539        let rendered = format!("{b:?}");
540        assert!(rendered.contains("hello"));
541    }
542
543    #[rstest]
544    fn slice_empty_descriptor_is_null_and_zero_len() {
545        let s: Slice<u32> = Slice::empty();
546        assert!(s.ptr.is_null());
547        assert_eq!(s.len, 0);
548    }
549
550    #[rstest]
551    fn owned_bytes_empty_is_empty() {
552        let owned = OwnedBytes::empty();
553        assert!(owned.is_empty());
554        assert!(owned.ptr.is_null());
555        assert_eq!(owned.len, 0);
556        assert_eq!(owned.cap, 0);
557        assert!(owned.drop_fn.is_none());
558        // SAFETY: empty OwnedBytes borrows as &[] without dereferencing.
559        assert!(unsafe { owned.as_bytes() }.is_empty());
560    }
561
562    #[rstest]
563    fn owned_bytes_is_empty_for_zero_length_buffer() {
564        let owned = OwnedBytes::from_vec(Vec::new());
565        assert!(owned.is_empty());
566        drop(owned);
567    }
568
569    #[rstest]
570    fn owned_bytes_drop_with_null_ptr_short_circuits() {
571        let owned = OwnedBytes {
572            ptr: ptr::null_mut(),
573            len: 0,
574            cap: 0,
575            drop_fn: Some(drop_owned_bytes),
576        };
577        // Should not panic or attempt to free a null pointer.
578        drop(owned);
579    }
580
581    #[rstest]
582    fn drop_owned_bytes_handles_null_ptr_without_panic() {
583        // SAFETY: documented contract: null pointer short-circuits.
584        unsafe {
585            drop_owned_bytes(ptr::null_mut(), 0, 0);
586        };
587    }
588
589    #[rstest]
590    fn drop_owned_bytes_frees_vec_leaked_with_from_vec_layout() {
591        let mut v = core::mem::ManuallyDrop::new(vec![1u8, 2, 3, 4, 5]);
592        let ptr = v.as_mut_ptr();
593        let len = v.len();
594        let cap = v.capacity();
595        // SAFETY: pointer/len/cap originate from a `Vec<u8>` leaked above;
596        // `drop_owned_bytes` reconstructs and drops it with the matching
597        // layout.
598        unsafe {
599            drop_owned_bytes(ptr, len, cap);
600        };
601    }
602
603    #[rstest]
604    fn plugin_error_new_carries_code_and_message() {
605        let err = PluginError::new(PluginErrorCode::InvalidArgument, "bad arg");
606        assert_eq!(err.code, PluginErrorCode::InvalidArgument);
607        assert_eq!(err.message_string(), "bad arg");
608    }
609
610    #[rstest]
611    fn plugin_error_panic_sets_panic_code() {
612        let err = PluginError::panic("oops");
613        assert_eq!(err.code, PluginErrorCode::Panic);
614        assert_eq!(err.message_string(), "oops");
615    }
616
617    #[rstest]
618    fn plugin_error_debug_renders_code_and_message() {
619        let err = PluginError::new(PluginErrorCode::NotImplemented, "todo");
620        let rendered = format!("{err:?}");
621        assert!(rendered.contains("NotImplemented"));
622        assert!(rendered.contains("todo"));
623    }
624
625    #[rstest]
626    #[case::ok(PluginErrorCode::Ok, 0u32)]
627    #[case::generic(PluginErrorCode::Generic, 1)]
628    #[case::panic(PluginErrorCode::Panic, 2)]
629    #[case::invalid_argument(PluginErrorCode::InvalidArgument, 3)]
630    #[case::not_implemented(PluginErrorCode::NotImplemented, 4)]
631    #[case::abi_mismatch(PluginErrorCode::AbiMismatch, 5)]
632    #[case::serialization_failed(PluginErrorCode::SerializationFailed, 6)]
633    fn plugin_error_code_has_stable_discriminant(
634        #[case] code: PluginErrorCode,
635        #[case] expected: u32,
636    ) {
637        assert_eq!(code as u32, expected);
638    }
639}