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
88impl core::fmt::Debug for BorrowedStr<'_> {
89    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
90        // SAFETY: Debug is best-effort; if the producer has dropped storage
91        // this would be UB. The plug-in contract pins manifest strings to
92        // process lifetime so reads here are sound.
93        let s = unsafe { self.as_str() };
94        write!(f, "BorrowedStr({s:?})")
95    }
96}
97
98/// A borrowed slice of `T` with a lifetime tied to the producer's storage.
99///
100/// Used in the manifest to enumerate per-trait registration entries without
101/// crossing the boundary with `Vec`.
102#[repr(C)]
103#[derive(Clone, Copy)]
104pub struct Slice<'a, T> {
105    pub ptr: *const T,
106    pub len: usize,
107    _phantom: PhantomData<&'a [T]>,
108}
109
110/// SAFETY: see [`BorrowedStr`].
111unsafe impl<T: Sync> Send for Slice<'_, T> {}
112/// SAFETY: see [`BorrowedStr`].
113unsafe impl<T: Sync> Sync for Slice<'_, T> {}
114
115impl<'a, T> Slice<'a, T> {
116    /// Returns an empty slice.
117    #[must_use]
118    pub const fn empty() -> Self {
119        Self {
120            ptr: ptr::null(),
121            len: 0,
122            _phantom: PhantomData,
123        }
124    }
125
126    /// Wraps a Rust slice as a boundary slice.
127    #[must_use]
128    pub const fn from_slice(s: &'a [T]) -> Self {
129        Self {
130            ptr: s.as_ptr(),
131            len: s.len(),
132            _phantom: PhantomData,
133        }
134    }
135
136    /// Borrows the slice as a `&[T]`.
137    ///
138    /// # Safety
139    ///
140    /// The caller must ensure the producing storage is still live.
141    #[must_use]
142    pub unsafe fn as_slice(&self) -> &'a [T] {
143        if self.ptr.is_null() || self.len == 0 {
144            return &[];
145        }
146        // SAFETY: caller upholds the lifetime.
147        unsafe { slice::from_raw_parts(self.ptr, self.len) }
148    }
149}
150
151/// Coarse-grained error categories for [`PluginError`].
152///
153/// Encoded as `u32` for stable wire representation.
154#[repr(u32)]
155#[derive(Clone, Copy, Debug, PartialEq, Eq)]
156pub enum PluginErrorCode {
157    Ok = 0,
158    Generic = 1,
159    Panic = 2,
160    InvalidArgument = 3,
161    NotImplemented = 4,
162    AbiMismatch = 5,
163    SerializationFailed = 6,
164}
165
166/// An owned byte buffer crossing the plug-in boundary.
167///
168/// Allocated by the producing side and freed by the producer's `drop_fn` so
169/// allocator mismatches between host and plug-in stay impossible. v1 uses
170/// this only for runtime-constructed error messages; data payloads cross via
171/// other paths (Arrow IPC for batches, JSON via `OwnedBytes` for single items).
172#[repr(C)]
173pub struct OwnedBytes {
174    pub ptr: *mut u8,
175    pub len: usize,
176    pub cap: usize,
177    pub drop_fn: Option<unsafe extern "C" fn(ptr: *mut u8, len: usize, cap: usize)>,
178}
179
180/// SAFETY: a heap pointer freed only by its producer's `drop_fn`; safe to
181/// transfer ownership across threads.
182unsafe impl Send for OwnedBytes {}
183
184impl OwnedBytes {
185    /// Constructs an empty `OwnedBytes` with no drop function.
186    #[must_use]
187    pub const fn empty() -> Self {
188        Self {
189            ptr: ptr::null_mut(),
190            len: 0,
191            cap: 0,
192            drop_fn: None,
193        }
194    }
195
196    /// Returns whether the buffer is empty (no allocation).
197    #[must_use]
198    pub fn is_empty(&self) -> bool {
199        self.len == 0 || self.ptr.is_null()
200    }
201
202    /// Constructs an `OwnedBytes` from a Rust `Vec<u8>` using the producer's
203    /// allocator and stamps the matching producer-side free as `drop_fn`.
204    ///
205    /// Consumers release the buffer by dropping the `OwnedBytes` (which
206    /// invokes the embedded `drop_fn`) or by calling that `drop_fn`
207    /// explicitly. Do not call [`drop_owned_bytes`] on a value received
208    /// across the plug-in boundary: that would free with the *consumer's*
209    /// allocator, which may not match the producer's. [`drop_owned_bytes`]
210    /// is only the default function installed here for the producer; each
211    /// side sees its own copy linked against its own allocator.
212    #[must_use]
213    pub fn from_vec(v: Vec<u8>) -> Self {
214        let mut v = core::mem::ManuallyDrop::new(v);
215        let ptr = v.as_mut_ptr();
216        let len = v.len();
217        let cap = v.capacity();
218        Self {
219            ptr,
220            len,
221            cap,
222            drop_fn: Some(drop_owned_bytes),
223        }
224    }
225
226    /// Borrows the buffer as a byte slice.
227    ///
228    /// # Safety
229    ///
230    /// The buffer must still be live (i.e. its `drop_fn` not yet called).
231    #[must_use]
232    pub unsafe fn as_bytes(&self) -> &[u8] {
233        if self.is_empty() {
234            return &[];
235        }
236        // SAFETY: caller upholds liveness.
237        unsafe { slice::from_raw_parts(self.ptr, self.len) }
238    }
239}
240
241impl Drop for OwnedBytes {
242    fn drop(&mut self) {
243        if let Some(f) = self.drop_fn.take()
244            && !self.ptr.is_null()
245        {
246            // SAFETY: ptr/len/cap originate from `from_vec` or from a
247            // matching producer; drop_fn is the matching free.
248            unsafe { f(self.ptr, self.len, self.cap) };
249            self.ptr = ptr::null_mut();
250            self.len = 0;
251            self.cap = 0;
252        }
253    }
254}
255
256/// Default `drop_fn` used by [`OwnedBytes::from_vec`]. Plug-ins that build
257/// `OwnedBytes` via `from_vec` get matching free behaviour automatically.
258///
259/// # Safety
260///
261/// The caller must pass `ptr`, `len`, and `cap` originally returned by a
262/// `Vec<u8>` that was leaked via `from_vec`.
263pub unsafe extern "C" fn drop_owned_bytes(ptr: *mut u8, len: usize, cap: usize) {
264    if ptr.is_null() {
265        return;
266    }
267    // SAFETY: pointer originates from `Vec::into_raw_parts`-style leak.
268    unsafe {
269        let _ = Vec::from_raw_parts(ptr, len, cap);
270    }
271}
272
273/// Generic plug-in error returned across the boundary.
274///
275/// `message` is owned by the producer; the consumer drops it via its
276/// `OwnedBytes` `drop_fn` once it has been logged or wrapped.
277#[repr(C)]
278pub struct PluginError {
279    pub code: PluginErrorCode,
280    pub message: OwnedBytes,
281}
282
283impl PluginError {
284    /// Constructs an error with a `Generic` code and a message string.
285    #[must_use]
286    pub fn generic(message: impl AsRef<str>) -> Self {
287        Self {
288            code: PluginErrorCode::Generic,
289            message: OwnedBytes::from_vec(message.as_ref().as_bytes().to_vec()),
290        }
291    }
292
293    /// Constructs an error with the given code and message string.
294    #[must_use]
295    pub fn new(code: PluginErrorCode, message: impl AsRef<str>) -> Self {
296        Self {
297            code,
298            message: OwnedBytes::from_vec(message.as_ref().as_bytes().to_vec()),
299        }
300    }
301
302    /// Constructs a panic error with the given message.
303    #[must_use]
304    pub fn panic(message: impl AsRef<str>) -> Self {
305        Self::new(PluginErrorCode::Panic, message)
306    }
307
308    /// Returns the message as a `String` (lossy if non-UTF8).
309    #[must_use]
310    pub fn message_string(&self) -> String {
311        // SAFETY: message is live until self is dropped.
312        let bytes = unsafe { self.message.as_bytes() };
313        String::from_utf8_lossy(bytes).into_owned()
314    }
315}
316
317impl core::fmt::Debug for PluginError {
318    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
319        f.debug_struct(stringify!(PluginError))
320            .field("code", &self.code)
321            .field("message", &self.message_string())
322            .finish()
323    }
324}
325
326/// A `Result`-shaped union for boundary calls.
327///
328/// `#[repr(C, u8)]` so the discriminant is a single byte at offset zero,
329/// independent of payload alignment.
330#[repr(C, u8)]
331pub enum PluginResult<T> {
332    Ok(T),
333    Err(PluginError),
334}
335
336impl<T> PluginResult<T> {
337    /// Converts to a `core::result::Result`, dropping the discriminant.
338    pub fn into_result(self) -> Result<T, PluginError> {
339        match self {
340            Self::Ok(t) => Ok(t),
341            Self::Err(e) => Err(e),
342        }
343    }
344
345    /// Wraps a `Result` produced inside Rust into a boundary result.
346    pub fn from_result(r: Result<T, PluginError>) -> Self {
347        match r {
348            Ok(t) => Self::Ok(t),
349            Err(e) => Self::Err(e),
350        }
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use std::sync::atomic::{AtomicUsize, Ordering};
357
358    use rstest::rstest;
359
360    use super::*;
361
362    #[rstest]
363    #[case::ascii("hello")]
364    #[case::empty("")]
365    #[case::utf8("héllo wörld")]
366    #[case::multibyte("\u{1F600}\u{1F4A9}")]
367    fn borrowed_str_round_trips(#[case] s: &str) {
368        let b = BorrowedStr::from_str(s);
369        // SAFETY: storage lives for the duration of this test.
370        let back = unsafe { b.as_str() };
371        assert_eq!(back, s);
372    }
373
374    #[rstest]
375    fn slice_round_trips() {
376        let data: [u32; 3] = [1, 2, 3];
377        let s = Slice::from_slice(&data);
378        // SAFETY: storage lives for the duration of this test.
379        let back = unsafe { s.as_slice() };
380        assert_eq!(back, &[1u32, 2, 3]);
381    }
382
383    #[rstest]
384    fn empty_slice_returns_empty() {
385        let s: Slice<u8> = Slice::empty();
386        // SAFETY: empty slice is always safe to view.
387        let back = unsafe { s.as_slice() };
388        assert!(back.is_empty());
389    }
390
391    #[rstest]
392    fn owned_bytes_round_trip_and_drop() {
393        let payload = b"hello world".to_vec();
394        let owned = OwnedBytes::from_vec(payload.clone());
395        // SAFETY: still live until owned drops.
396        let view = unsafe { owned.as_bytes() }.to_vec();
397        assert_eq!(view, payload);
398        drop(owned);
399    }
400
401    #[rstest]
402    fn owned_bytes_drop_fn_runs_exactly_once() {
403        static COUNTER: AtomicUsize = AtomicUsize::new(0);
404        unsafe extern "C" fn counting_drop(ptr: *mut u8, len: usize, cap: usize) {
405            if !ptr.is_null() {
406                COUNTER.fetch_add(1, Ordering::SeqCst);
407                // SAFETY: pointer originates from the boxed slice leaked below.
408                unsafe {
409                    let _ = Vec::from_raw_parts(ptr, len, cap);
410                }
411            }
412        }
413
414        COUNTER.store(0, Ordering::SeqCst);
415        let mut v = core::mem::ManuallyDrop::new(vec![1u8, 2, 3, 4]);
416        let ptr = v.as_mut_ptr();
417        let len = v.len();
418        let cap = v.capacity();
419        let owned = OwnedBytes {
420            ptr,
421            len,
422            cap,
423            drop_fn: Some(counting_drop),
424        };
425        assert_eq!(COUNTER.load(Ordering::SeqCst), 0);
426        drop(owned);
427        assert_eq!(COUNTER.load(Ordering::SeqCst), 1);
428    }
429
430    #[rstest]
431    fn plugin_error_carries_message() {
432        let err = PluginError::generic("bad input");
433        assert_eq!(err.code, PluginErrorCode::Generic);
434        assert_eq!(err.message_string(), "bad input");
435    }
436
437    #[rstest]
438    fn plugin_result_round_trips() {
439        let ok: PluginResult<u32> = PluginResult::Ok(42);
440        let r = ok.into_result();
441        assert_eq!(r.unwrap(), 42);
442
443        let err: PluginResult<u32> = PluginResult::Err(PluginError::generic("nope"));
444        let r = err.into_result();
445        assert!(r.is_err());
446    }
447
448    #[rstest]
449    fn plugin_result_from_result_round_trips() {
450        let r: PluginResult<u32> = PluginResult::from_result(Ok(7));
451        assert_eq!(r.into_result().unwrap(), 7);
452
453        let r: PluginResult<u32> = PluginResult::from_result(Err(PluginError::generic("x")));
454        let e = r.into_result().unwrap_err();
455        assert_eq!(e.code, PluginErrorCode::Generic);
456        assert_eq!(e.message_string(), "x");
457    }
458
459    #[rstest]
460    fn borrowed_str_empty_is_empty_when_borrowed_back() {
461        let b = BorrowedStr::empty();
462        assert!(b.ptr.is_null());
463        assert_eq!(b.len, 0);
464        // SAFETY: an empty BorrowedStr returns "" without dereferencing.
465        assert_eq!(unsafe { b.as_str() }, "");
466    }
467
468    #[rstest]
469    fn borrowed_str_debug_prints_contents() {
470        let b = BorrowedStr::from_str("hello");
471        let rendered = format!("{b:?}");
472        assert!(rendered.contains("hello"));
473    }
474
475    #[rstest]
476    fn slice_empty_descriptor_is_null_and_zero_len() {
477        let s: Slice<u32> = Slice::empty();
478        assert!(s.ptr.is_null());
479        assert_eq!(s.len, 0);
480    }
481
482    #[rstest]
483    fn owned_bytes_empty_is_empty() {
484        let owned = OwnedBytes::empty();
485        assert!(owned.is_empty());
486        assert!(owned.ptr.is_null());
487        assert_eq!(owned.len, 0);
488        assert_eq!(owned.cap, 0);
489        assert!(owned.drop_fn.is_none());
490        // SAFETY: empty OwnedBytes borrows as &[] without dereferencing.
491        assert!(unsafe { owned.as_bytes() }.is_empty());
492    }
493
494    #[rstest]
495    fn owned_bytes_is_empty_for_zero_length_buffer() {
496        let owned = OwnedBytes::from_vec(Vec::new());
497        assert!(owned.is_empty());
498        drop(owned);
499    }
500
501    #[rstest]
502    fn owned_bytes_drop_with_null_ptr_short_circuits() {
503        let owned = OwnedBytes {
504            ptr: ptr::null_mut(),
505            len: 0,
506            cap: 0,
507            drop_fn: Some(drop_owned_bytes),
508        };
509        // Should not panic or attempt to free a null pointer.
510        drop(owned);
511    }
512
513    #[rstest]
514    fn drop_owned_bytes_handles_null_ptr_without_panic() {
515        // SAFETY: documented contract: null pointer short-circuits.
516        unsafe {
517            drop_owned_bytes(ptr::null_mut(), 0, 0);
518        };
519    }
520
521    #[rstest]
522    fn drop_owned_bytes_frees_vec_leaked_with_from_vec_layout() {
523        let mut v = core::mem::ManuallyDrop::new(vec![1u8, 2, 3, 4, 5]);
524        let ptr = v.as_mut_ptr();
525        let len = v.len();
526        let cap = v.capacity();
527        // SAFETY: pointer/len/cap originate from a `Vec<u8>` leaked above;
528        // `drop_owned_bytes` reconstructs and drops it with the matching
529        // layout.
530        unsafe {
531            drop_owned_bytes(ptr, len, cap);
532        };
533    }
534
535    #[rstest]
536    fn plugin_error_new_carries_code_and_message() {
537        let err = PluginError::new(PluginErrorCode::InvalidArgument, "bad arg");
538        assert_eq!(err.code, PluginErrorCode::InvalidArgument);
539        assert_eq!(err.message_string(), "bad arg");
540    }
541
542    #[rstest]
543    fn plugin_error_panic_sets_panic_code() {
544        let err = PluginError::panic("oops");
545        assert_eq!(err.code, PluginErrorCode::Panic);
546        assert_eq!(err.message_string(), "oops");
547    }
548
549    #[rstest]
550    fn plugin_error_debug_renders_code_and_message() {
551        let err = PluginError::new(PluginErrorCode::NotImplemented, "todo");
552        let rendered = format!("{err:?}");
553        assert!(rendered.contains("NotImplemented"));
554        assert!(rendered.contains("todo"));
555    }
556
557    #[rstest]
558    #[case::ok(PluginErrorCode::Ok, 0u32)]
559    #[case::generic(PluginErrorCode::Generic, 1)]
560    #[case::panic(PluginErrorCode::Panic, 2)]
561    #[case::invalid_argument(PluginErrorCode::InvalidArgument, 3)]
562    #[case::not_implemented(PluginErrorCode::NotImplemented, 4)]
563    #[case::abi_mismatch(PluginErrorCode::AbiMismatch, 5)]
564    #[case::serialization_failed(PluginErrorCode::SerializationFailed, 6)]
565    fn plugin_error_code_has_stable_discriminant(
566        #[case] code: PluginErrorCode,
567        #[case] expected: u32,
568    ) {
569        assert_eq!(code as u32, expected);
570    }
571}