Skip to main content

fidius_host/
handle.rs

1// Copyright 2026 Colliery, Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
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//! PluginHandle — type-safe proxy for calling plugin methods via FFI.
16
17use std::ffi::c_void;
18use std::sync::Arc;
19
20use libloading::Library;
21use serde::de::DeserializeOwned;
22use serde::Serialize;
23
24use fidius_core::descriptor::{BufferStrategyKind, PluginDescriptor};
25use fidius_core::status::*;
26use fidius_core::wire;
27use fidius_core::PluginError;
28
29use crate::arena::{acquire_arena, grow_arena, release_arena, DEFAULT_ARENA_CAPACITY};
30use crate::error::{CallError, LoadError};
31use crate::types::PluginInfo;
32
33/// Type alias for the PluginAllocated FFI function pointer signature.
34type FfiFn = unsafe extern "C" fn(*const u8, u32, *mut *mut u8, *mut u32) -> i32;
35
36/// Type alias for the Arena FFI function pointer signature.
37type ArenaFn = unsafe extern "C" fn(*const u8, u32, *mut u8, u32, *mut u32, *mut u32) -> i32;
38
39/// A handle to a loaded plugin, ready for calling methods.
40///
41/// Holds an `Arc<Library>` to keep the dylib loaded as long as any handle exists.
42/// Call methods via `call_method()` which handles serialization, FFI, and cleanup.
43///
44/// `PluginHandle` is `Send + Sync`. Plugin methods take `&self` (enforced by
45/// the macro), so concurrent calls from multiple threads are safe as long as
46/// the plugin implementation is thread-safe internally.
47pub struct PluginHandle {
48    /// Keeps the library alive for dylib-loaded plugins. `None` for in-process
49    /// handles built via [`PluginHandle::from_descriptor`] — in-process plugins
50    /// live in the current binary's address space and don't need Arc-tracking.
51    _library: Option<Arc<Library>>,
52    /// Pointer to the `#[repr(C)]` vtable struct in the loaded library.
53    vtable: *const c_void,
54    /// Pointer to the full descriptor in library memory. Used by metadata
55    /// accessors to read `method_metadata` / `trait_metadata`. Valid for the
56    /// handle's lifetime via `_library` Arc (or forever for in-process).
57    descriptor: *const PluginDescriptor,
58    /// Free function for plugin-allocated output buffers.
59    free_buffer: Option<unsafe extern "C" fn(*mut u8, usize)>,
60    /// Capability bitfield for optional method support.
61    capabilities: u64,
62    /// Total number of methods in the vtable.
63    method_count: u32,
64    /// Owned plugin metadata.
65    info: PluginInfo,
66}
67
68// SAFETY: PluginHandle is Send + Sync because:
69// - vtable and free_buffer are function pointers to static code in the loaded library
70// - Arc<Library> is Send + Sync and ensures the library stays loaded
71// - All access through call_method is read-only (no mutation of handle state)
72//
73// Plugin implementations must be thread-safe (&self methods, no &mut self)
74// if the PluginHandle is shared across threads. This is enforced at compile
75// time by the #[plugin_interface] macro which rejects &mut self methods.
76unsafe impl Send for PluginHandle {}
77unsafe impl Sync for PluginHandle {}
78
79impl PluginHandle {
80    /// Create a new PluginHandle. Crate-private — use `from_loaded()` instead.
81    #[allow(dead_code)]
82    pub(crate) fn new(
83        library: Arc<Library>,
84        vtable: *const c_void,
85        descriptor: *const PluginDescriptor,
86        free_buffer: Option<unsafe extern "C" fn(*mut u8, usize)>,
87        capabilities: u64,
88        method_count: u32,
89        info: PluginInfo,
90    ) -> Self {
91        Self {
92            _library: Some(library),
93            vtable,
94            descriptor,
95            free_buffer,
96            capabilities,
97            method_count,
98            info,
99        }
100    }
101
102    /// Create a PluginHandle from a LoadedPlugin.
103    pub fn from_loaded(plugin: crate::loader::LoadedPlugin) -> Self {
104        Self {
105            _library: Some(plugin.library),
106            vtable: plugin.vtable,
107            descriptor: plugin.descriptor,
108            free_buffer: plugin.free_buffer,
109            capabilities: plugin.info.capabilities,
110            method_count: plugin.method_count,
111            info: plugin.info,
112        }
113    }
114
115    /// Create a PluginHandle from a plugin descriptor already registered in
116    /// the current process's inventory (via a `#[plugin_impl]` linked into
117    /// the current binary as a normal rlib). No dylib is loaded — the
118    /// descriptor's vtable points at code in the current binary.
119    ///
120    /// Used by the generated `Client::in_process(plugin_name)` constructor.
121    /// Host applications normally use [`PluginHandle::from_loaded`] instead.
122    pub fn from_descriptor(desc: &'static PluginDescriptor) -> Result<Self, LoadError> {
123        let info = PluginInfo {
124            name: unsafe { desc.plugin_name_str() }.to_string(),
125            interface_name: unsafe { desc.interface_name_str() }.to_string(),
126            interface_hash: desc.interface_hash,
127            interface_version: desc.interface_version,
128            capabilities: desc.capabilities,
129            buffer_strategy: desc
130                .buffer_strategy_kind()
131                .map_err(|v| LoadError::UnknownBufferStrategy { value: v })?,
132            runtime: crate::types::PluginRuntimeKind::Cdylib,
133        };
134        Ok(Self {
135            _library: None,
136            vtable: desc.vtable,
137            descriptor: desc as *const PluginDescriptor,
138            free_buffer: desc.free_buffer,
139            capabilities: desc.capabilities,
140            method_count: desc.method_count,
141            info,
142        })
143    }
144
145    /// Look up a descriptor in the current process's inventory registry by
146    /// `plugin_name` (the Rust struct name that was passed to `#[plugin_impl]`).
147    /// Returns `LoadError::PluginNotFound` if no descriptor has that name.
148    ///
149    /// The returned reference has `'static` lifetime because descriptors
150    /// emitted by `#[plugin_impl]` live in the binary's `.rodata`.
151    pub fn find_in_process_descriptor(
152        plugin_name: &str,
153    ) -> Result<&'static PluginDescriptor, LoadError> {
154        let reg = fidius_core::registry::get_registry();
155        for i in 0..reg.plugin_count as usize {
156            let desc_ptr = unsafe { *reg.descriptors.add(i) };
157            let desc = unsafe { &*desc_ptr };
158            if unsafe { desc.plugin_name_str() } == plugin_name {
159                return Ok(desc);
160            }
161        }
162        Err(LoadError::PluginNotFound {
163            name: plugin_name.to_string(),
164        })
165    }
166
167    /// Call a plugin method by vtable index.
168    ///
169    /// Serializes the input, calls the FFI function pointer at the given index,
170    /// checks the status code, deserializes the output, and frees the plugin-allocated buffer.
171    ///
172    /// # Arguments
173    /// * `index` - The method index in the vtable (0-based, in declaration order)
174    /// * `input` - The input argument to serialize and pass to the plugin
175    ///
176    /// # No timeout
177    ///
178    /// This call runs synchronously on the calling thread and has no built-in
179    /// timeout or cancellation. A misbehaving plugin will block the caller
180    /// indefinitely. See the `fidius` crate top-level docs ("What fidius
181    /// does not provide") for the rationale and the recommended consumer-side
182    /// mitigation.
183    pub fn call_method<I: Serialize, O: DeserializeOwned>(
184        &self,
185        index: usize,
186        input: &I,
187    ) -> Result<O, CallError> {
188        // Bounds check: ensure index is within the vtable
189        if index >= self.method_count as usize {
190            return Err(CallError::InvalidMethodIndex {
191                index,
192                count: self.method_count,
193            });
194        }
195
196        let input_bytes =
197            wire::serialize(input).map_err(|e| CallError::Serialization(e.to_string()))?;
198
199        match self.info.buffer_strategy {
200            BufferStrategyKind::PluginAllocated => self.call_plugin_allocated(index, &input_bytes),
201            BufferStrategyKind::Arena => self.call_arena(index, &input_bytes),
202        }
203    }
204
205    /// Call a plugin method whose argument and successful return value are
206    /// raw bytes — bypassing bincode on both sides. Used by methods declared
207    /// with `#[wire(raw)]` on the interface trait.
208    ///
209    /// Errors and panic messages still use bincode (small typed payloads).
210    /// Returns the success bytes on `Ok`, or a `CallError::Plugin(_)` whose
211    /// inner `PluginError` was bincode-decoded from the plugin's error payload.
212    ///
213    /// Same no-timeout caveat as [`Self::call_method`].
214    pub fn call_method_raw(&self, index: usize, input: &[u8]) -> Result<Vec<u8>, CallError> {
215        if index >= self.method_count as usize {
216            return Err(CallError::InvalidMethodIndex {
217                index,
218                count: self.method_count,
219            });
220        }
221        match self.info.buffer_strategy {
222            BufferStrategyKind::PluginAllocated => self.call_plugin_allocated_raw(index, input),
223            BufferStrategyKind::Arena => self.call_arena_raw(index, input),
224        }
225    }
226
227    /// PluginAllocated path: plugin allocates an output buffer via
228    /// `Box::into_raw(Box<[u8]>)`, host deserializes and calls free_buffer.
229    fn call_plugin_allocated<O: DeserializeOwned>(
230        &self,
231        index: usize,
232        input_bytes: &[u8],
233    ) -> Result<O, CallError> {
234        let fn_ptr = unsafe {
235            let fn_ptrs = self.vtable as *const FfiFn;
236            *fn_ptrs.add(index)
237        };
238
239        let mut out_ptr: *mut u8 = std::ptr::null_mut();
240        let mut out_len: u32 = 0;
241
242        let status = unsafe {
243            fn_ptr(
244                input_bytes.as_ptr(),
245                input_bytes.len() as u32,
246                &mut out_ptr,
247                &mut out_len,
248            )
249        };
250
251        match status {
252            STATUS_OK => {}
253            STATUS_BUFFER_TOO_SMALL => return Err(CallError::BufferTooSmall),
254            STATUS_SERIALIZATION_ERROR => {
255                return Err(CallError::Serialization("FFI serialization failed".into()))
256            }
257            STATUS_PLUGIN_ERROR => {
258                if !out_ptr.is_null() && out_len > 0 {
259                    let output_slice =
260                        unsafe { std::slice::from_raw_parts(out_ptr, out_len as usize) };
261                    let plugin_err: PluginError = wire::deserialize(output_slice)
262                        .map_err(|e| CallError::Deserialization(e.to_string()))?;
263
264                    if let Some(free) = self.free_buffer {
265                        unsafe { free(out_ptr, out_len as usize) };
266                    }
267
268                    return Err(CallError::Plugin(plugin_err));
269                }
270                return Err(CallError::Plugin(PluginError::new(
271                    "UNKNOWN",
272                    "plugin returned error but no error data",
273                )));
274            }
275            STATUS_PANIC => {
276                let msg = if !out_ptr.is_null() && out_len > 0 {
277                    let slice = unsafe { std::slice::from_raw_parts(out_ptr, out_len as usize) };
278                    let msg = wire::deserialize::<String>(slice)
279                        .unwrap_or_else(|_| "unknown panic".into());
280                    if let Some(free) = self.free_buffer {
281                        unsafe { free(out_ptr, out_len as usize) };
282                    }
283                    msg
284                } else {
285                    "unknown panic".into()
286                };
287                return Err(CallError::Panic(msg));
288            }
289            _ => return Err(CallError::UnknownStatus { code: status }),
290        }
291
292        if out_ptr.is_null() {
293            return Err(CallError::Serialization(
294                "plugin returned null output buffer".into(),
295            ));
296        }
297
298        let output_slice = unsafe { std::slice::from_raw_parts(out_ptr, out_len as usize) };
299        let result: Result<O, CallError> =
300            wire::deserialize(output_slice).map_err(|e| CallError::Deserialization(e.to_string()));
301
302        if let Some(free) = self.free_buffer {
303            unsafe { free(out_ptr, out_len as usize) };
304        }
305
306        result
307    }
308
309    /// Arena path: host supplies a buffer from the thread-local pool. If the
310    /// plugin reports `STATUS_BUFFER_TOO_SMALL`, grow the buffer to the
311    /// requested size and retry exactly once (second too-small would indicate
312    /// a misbehaving plugin — bail with `CallError::BufferTooSmall`).
313    fn call_arena<O: DeserializeOwned>(
314        &self,
315        index: usize,
316        input_bytes: &[u8],
317    ) -> Result<O, CallError> {
318        let fn_ptr = unsafe {
319            let fn_ptrs = self.vtable as *const ArenaFn;
320            *fn_ptrs.add(index)
321        };
322
323        let mut arena = acquire_arena(DEFAULT_ARENA_CAPACITY);
324        let mut out_offset: u32 = 0;
325        let mut out_len: u32 = 0;
326        let mut retried = false;
327
328        let status = loop {
329            let s = unsafe {
330                fn_ptr(
331                    input_bytes.as_ptr(),
332                    input_bytes.len() as u32,
333                    arena.as_mut_ptr(),
334                    arena.len() as u32,
335                    &mut out_offset,
336                    &mut out_len,
337                )
338            };
339            if s == STATUS_BUFFER_TOO_SMALL && !retried {
340                // Plugin wrote the needed size into out_len. Grow and retry once.
341                let needed = out_len as usize;
342                grow_arena(&mut arena, needed);
343                retried = true;
344                continue;
345            }
346            break s;
347        };
348
349        match status {
350            STATUS_OK => {
351                let start = out_offset as usize;
352                let end = start + out_len as usize;
353                if end > arena.len() {
354                    release_arena(arena);
355                    return Err(CallError::Serialization(
356                        "plugin reported out_offset/out_len outside arena".into(),
357                    ));
358                }
359                let result = wire::deserialize(&arena[start..end])
360                    .map_err(|e| CallError::Deserialization(e.to_string()));
361                release_arena(arena);
362                result
363            }
364            STATUS_BUFFER_TOO_SMALL => {
365                release_arena(arena);
366                Err(CallError::BufferTooSmall)
367            }
368            STATUS_SERIALIZATION_ERROR => {
369                release_arena(arena);
370                Err(CallError::Serialization("FFI serialization failed".into()))
371            }
372            STATUS_PLUGIN_ERROR => {
373                let start = out_offset as usize;
374                let end = start + out_len as usize;
375                let plugin_err = if out_len > 0 && end <= arena.len() {
376                    wire::deserialize::<PluginError>(&arena[start..end]).unwrap_or_else(|_| {
377                        PluginError::new("UNKNOWN", "plugin returned malformed error")
378                    })
379                } else {
380                    PluginError::new("UNKNOWN", "plugin returned error but no error data")
381                };
382                release_arena(arena);
383                Err(CallError::Plugin(plugin_err))
384            }
385            STATUS_PANIC => {
386                // Arena strategy's panic path returns out_len = 0 (the arena
387                // might be too small for the panic message). Host can't
388                // recover a message; report an opaque panic.
389                release_arena(arena);
390                Err(CallError::Panic(
391                    "plugin panicked (message not transmitted via Arena strategy)".into(),
392                ))
393            }
394            code => {
395                release_arena(arena);
396                Err(CallError::UnknownStatus { code })
397            }
398        }
399    }
400
401    /// PluginAllocated raw path — same FFI shape as `call_plugin_allocated`,
402    /// but the success buffer is returned to the caller as-is rather than
403    /// fed to bincode.
404    fn call_plugin_allocated_raw(
405        &self,
406        index: usize,
407        input_bytes: &[u8],
408    ) -> Result<Vec<u8>, CallError> {
409        let fn_ptr = unsafe {
410            let fn_ptrs = self.vtable as *const FfiFn;
411            *fn_ptrs.add(index)
412        };
413
414        let mut out_ptr: *mut u8 = std::ptr::null_mut();
415        let mut out_len: u32 = 0;
416
417        let status = unsafe {
418            fn_ptr(
419                input_bytes.as_ptr(),
420                input_bytes.len() as u32,
421                &mut out_ptr,
422                &mut out_len,
423            )
424        };
425
426        match status {
427            STATUS_OK => {}
428            STATUS_BUFFER_TOO_SMALL => return Err(CallError::BufferTooSmall),
429            STATUS_SERIALIZATION_ERROR => {
430                return Err(CallError::Serialization("FFI serialization failed".into()))
431            }
432            STATUS_PLUGIN_ERROR => {
433                if !out_ptr.is_null() && out_len > 0 {
434                    let output_slice =
435                        unsafe { std::slice::from_raw_parts(out_ptr, out_len as usize) };
436                    let plugin_err: PluginError = wire::deserialize(output_slice)
437                        .map_err(|e| CallError::Deserialization(e.to_string()))?;
438                    if let Some(free) = self.free_buffer {
439                        unsafe { free(out_ptr, out_len as usize) };
440                    }
441                    return Err(CallError::Plugin(plugin_err));
442                }
443                return Err(CallError::Plugin(PluginError::new(
444                    "UNKNOWN",
445                    "plugin returned error but no error data",
446                )));
447            }
448            STATUS_PANIC => {
449                let msg = if !out_ptr.is_null() && out_len > 0 {
450                    let slice = unsafe { std::slice::from_raw_parts(out_ptr, out_len as usize) };
451                    let msg = wire::deserialize::<String>(slice)
452                        .unwrap_or_else(|_| "unknown panic".into());
453                    if let Some(free) = self.free_buffer {
454                        unsafe { free(out_ptr, out_len as usize) };
455                    }
456                    msg
457                } else {
458                    "unknown panic".into()
459                };
460                return Err(CallError::Panic(msg));
461            }
462            _ => return Err(CallError::UnknownStatus { code: status }),
463        }
464
465        if out_ptr.is_null() {
466            return Err(CallError::Serialization(
467                "plugin returned null output buffer".into(),
468            ));
469        }
470
471        // Copy the success bytes into a Vec, then free the plugin's buffer.
472        // This matches the existing Box<[u8]> ownership contract — the plugin
473        // owns the memory until `free_buffer` is called.
474        let output_slice = unsafe { std::slice::from_raw_parts(out_ptr, out_len as usize) };
475        let result = output_slice.to_vec();
476
477        if let Some(free) = self.free_buffer {
478            unsafe { free(out_ptr, out_len as usize) };
479        }
480
481        Ok(result)
482    }
483
484    /// Arena raw path — same FFI shape as `call_arena`, success bytes
485    /// returned as a `Vec<u8>` copied out of the arena.
486    fn call_arena_raw(&self, index: usize, input_bytes: &[u8]) -> Result<Vec<u8>, CallError> {
487        let fn_ptr = unsafe {
488            let fn_ptrs = self.vtable as *const ArenaFn;
489            *fn_ptrs.add(index)
490        };
491
492        let mut arena = acquire_arena(DEFAULT_ARENA_CAPACITY);
493        let mut out_offset: u32 = 0;
494        let mut out_len: u32 = 0;
495        let mut retried = false;
496
497        let status = loop {
498            let s = unsafe {
499                fn_ptr(
500                    input_bytes.as_ptr(),
501                    input_bytes.len() as u32,
502                    arena.as_mut_ptr(),
503                    arena.len() as u32,
504                    &mut out_offset,
505                    &mut out_len,
506                )
507            };
508            if s == STATUS_BUFFER_TOO_SMALL && !retried {
509                let needed = out_len as usize;
510                grow_arena(&mut arena, needed);
511                retried = true;
512                continue;
513            }
514            break s;
515        };
516
517        match status {
518            STATUS_OK => {
519                let start = out_offset as usize;
520                let end = start + out_len as usize;
521                if end > arena.len() {
522                    release_arena(arena);
523                    return Err(CallError::Serialization(
524                        "plugin reported out_offset/out_len outside arena".into(),
525                    ));
526                }
527                let result = arena[start..end].to_vec();
528                release_arena(arena);
529                Ok(result)
530            }
531            STATUS_BUFFER_TOO_SMALL => {
532                release_arena(arena);
533                Err(CallError::BufferTooSmall)
534            }
535            STATUS_SERIALIZATION_ERROR => {
536                release_arena(arena);
537                Err(CallError::Serialization("FFI serialization failed".into()))
538            }
539            STATUS_PLUGIN_ERROR => {
540                let start = out_offset as usize;
541                let end = start + out_len as usize;
542                let plugin_err = if out_len > 0 && end <= arena.len() {
543                    wire::deserialize::<PluginError>(&arena[start..end]).unwrap_or_else(|_| {
544                        PluginError::new("UNKNOWN", "plugin returned malformed error")
545                    })
546                } else {
547                    PluginError::new("UNKNOWN", "plugin returned error but no error data")
548                };
549                release_arena(arena);
550                Err(CallError::Plugin(plugin_err))
551            }
552            STATUS_PANIC => {
553                release_arena(arena);
554                Err(CallError::Panic(
555                    "plugin panicked (message not transmitted via Arena strategy)".into(),
556                ))
557            }
558            code => {
559                release_arena(arena);
560                Err(CallError::UnknownStatus { code })
561            }
562        }
563    }
564
565    /// Check if an optional method is supported (capability bit is set).
566    ///
567    /// Returns `false` for bit indices >= 64 rather than panicking.
568    pub fn has_capability(&self, bit: u32) -> bool {
569        if bit >= 64 {
570            return false;
571        }
572        self.capabilities & (1u64 << bit) != 0
573    }
574
575    /// Access the plugin's owned metadata.
576    pub fn info(&self) -> &PluginInfo {
577        &self.info
578    }
579
580    /// Returns the static key/value metadata declared on the given method via
581    /// `#[method_meta(...)]` attributes on the trait, in declaration order.
582    ///
583    /// Returns an empty `Vec` if:
584    /// - `method_id >= method_count` (out of range)
585    /// - the interface declared no method metadata on any method
586    /// - this specific method has no metadata declared
587    ///
588    /// The returned `&str` slices borrow from the loaded library's `.rodata`
589    /// (for dylib-loaded handles) or from the current binary's `.rodata`
590    /// (for in-process handles). The handle's lifetime bounds them safely.
591    pub fn method_metadata(&self, method_id: u32) -> Vec<(&str, &str)> {
592        if method_id >= self.method_count {
593            return Vec::new();
594        }
595        // SAFETY: descriptor pointer is valid for the handle's lifetime.
596        let desc = unsafe { &*self.descriptor };
597        if desc.method_metadata.is_null() {
598            return Vec::new();
599        }
600        // SAFETY: when method_metadata is non-null, it points at an array
601        // of method_count entries (codegen invariant).
602        let entries =
603            unsafe { std::slice::from_raw_parts(desc.method_metadata, self.method_count as usize) };
604        let entry = &entries[method_id as usize];
605        if entry.kvs.is_null() || entry.kv_count == 0 {
606            return Vec::new();
607        }
608        // SAFETY: kvs points at an array of kv_count MetaKv entries.
609        let kvs = unsafe { std::slice::from_raw_parts(entry.kvs, entry.kv_count as usize) };
610        kvs.iter()
611            .map(|kv| {
612                // SAFETY: both pointers are static, null-terminated UTF-8
613                // per the ABI contract enforced by the macro.
614                let k = unsafe { std::ffi::CStr::from_ptr(kv.key) }
615                    .to_str()
616                    .expect("metadata key is not valid UTF-8");
617                let v = unsafe { std::ffi::CStr::from_ptr(kv.value) }
618                    .to_str()
619                    .expect("metadata value is not valid UTF-8");
620                (k, v)
621            })
622            .collect()
623    }
624
625    /// Returns the static key/value metadata declared on the trait via
626    /// `#[trait_meta(...)]` attributes, in declaration order.
627    ///
628    /// Returns an empty `Vec` if no trait-level metadata was declared.
629    pub fn trait_metadata(&self) -> Vec<(&str, &str)> {
630        // SAFETY: descriptor pointer is valid for the handle's lifetime.
631        let desc = unsafe { &*self.descriptor };
632        if desc.trait_metadata.is_null() || desc.trait_metadata_count == 0 {
633            return Vec::new();
634        }
635        // SAFETY: trait_metadata points at an array of trait_metadata_count entries.
636        let kvs = unsafe {
637            std::slice::from_raw_parts(desc.trait_metadata, desc.trait_metadata_count as usize)
638        };
639        kvs.iter()
640            .map(|kv| {
641                let k = unsafe { std::ffi::CStr::from_ptr(kv.key) }
642                    .to_str()
643                    .expect("trait metadata key is not valid UTF-8");
644                let v = unsafe { std::ffi::CStr::from_ptr(kv.value) }
645                    .to_str()
646                    .expect("trait metadata value is not valid UTF-8");
647                (k, v)
648            })
649            .collect()
650    }
651}