Skip to main content

confium_api/
metadata.rs

1//! Plugin metadata exposed via the optional `cfmp_metadata` symbol.
2//!
3//! The wire shape mirrors `confium_core::ffi::plugin::CFMPluginMetadata`
4//! — a `#[repr(C)]` struct of `*const c_char` fields. Plugin authors use
5//! [`PluginMetadataBuilder`] to construct the value at static-storage
6//! time, and the `export!` macro generates the `cfmp_metadata` entry
7//! point that returns a pointer to it.
8//!
9//! Field strings are owned by the plugin (stored as `'static` so they
10//! outlive the loader's borrow). The returned pointer is valid for the
11//! lifetime of the loaded plugin.
12
13use std::ffi::CString;
14use std::os::raw::c_char;
15
16/// `#[repr(C)]` metadata struct returned by `cfmp_metadata`. Layout
17/// matches `confium_core::ffi::plugin::CFMPluginMetadata` so the loader
18/// can reinterpret the pointer.
19///
20/// Wire-stable: never reorder, repurpose, or remove existing fields.
21#[repr(C)]
22pub struct PluginMetadata {
23    pub name: *const c_char,
24    pub version: *const c_char,
25    pub vendor: *const c_char,
26    pub license: *const c_char,
27    pub homepage_url: *const c_char,
28    pub source_url: *const c_char,
29    pub issue_tracker_url: *const c_char,
30    pub description: *const c_char,
31}
32
33// SAFETY: `PluginMetadata` is logically immutable after construction.
34// The raw `*const c_char` fields point at `'static` storage owned by
35// the plugin (leaked `CString`s in the `PluginMetadataBuilder::build`
36// path). The struct is only read by the loader's `cfmp_metadata` call,
37// never mutated. Sharing it across threads is sound.
38unsafe impl Sync for PluginMetadata {}
39unsafe impl Send for PluginMetadata {}
40
41/// Builder for [`PluginMetadata`]. Each call leaks a `CString` so the
42/// returned pointer is `'static` (the value lives for the lifetime of
43/// the plugin, matching the contract).
44///
45/// Use via the `#[plugin_metadata]` proc-macro attribute on the
46/// `export!`-annotated module — the macro constructs the builder for
47/// you from attribute arguments.
48#[derive(Default)]
49pub struct PluginMetadataBuilder {
50    name: Option<CString>,
51    version: Option<CString>,
52    vendor: Option<CString>,
53    license: Option<CString>,
54    homepage_url: Option<CString>,
55    source_url: Option<CString>,
56    issue_tracker_url: Option<CString>,
57    description: Option<CString>,
58}
59
60impl PluginMetadataBuilder {
61    pub fn new() -> Self {
62        Self::default()
63    }
64
65    pub fn name(mut self, s: impl Into<String>) -> Self {
66        self.name = CString::new(s.into()).ok();
67        self
68    }
69    pub fn version(mut self, s: impl Into<String>) -> Self {
70        self.version = CString::new(s.into()).ok();
71        self
72    }
73    pub fn vendor(mut self, s: impl Into<String>) -> Self {
74        self.vendor = CString::new(s.into()).ok();
75        self
76    }
77    pub fn license(mut self, s: impl Into<String>) -> Self {
78        self.license = CString::new(s.into()).ok();
79        self
80    }
81    pub fn homepage_url(mut self, s: impl Into<String>) -> Self {
82        self.homepage_url = CString::new(s.into()).ok();
83        self
84    }
85    pub fn source_url(mut self, s: impl Into<String>) -> Self {
86        self.source_url = CString::new(s.into()).ok();
87        self
88    }
89    pub fn issue_tracker_url(mut self, s: impl Into<String>) -> Self {
90        self.issue_tracker_url = CString::new(s.into()).ok();
91        self
92    }
93    pub fn description(mut self, s: impl Into<String>) -> Self {
94        self.description = CString::new(s.into()).ok();
95        self
96    }
97
98    /// Materialize the metadata. The returned struct holds raw pointers
99    /// to `'static`-lifetime strings (the `CString`s are leaked to match
100    /// the plugin contract that the pointer is valid for the plugin's
101    /// lifetime).
102    pub fn build(self) -> PluginMetadata {
103        PluginMetadata {
104            name: self.name.map(leak_cstring).unwrap_or(std::ptr::null()),
105            version: self.version.map(leak_cstring).unwrap_or(std::ptr::null()),
106            vendor: self.vendor.map(leak_cstring).unwrap_or(std::ptr::null()),
107            license: self.license.map(leak_cstring).unwrap_or(std::ptr::null()),
108            homepage_url: self
109                .homepage_url
110                .map(leak_cstring)
111                .unwrap_or(std::ptr::null()),
112            source_url: self
113                .source_url
114                .map(leak_cstring)
115                .unwrap_or(std::ptr::null()),
116            issue_tracker_url: self
117                .issue_tracker_url
118                .map(leak_cstring)
119                .unwrap_or(std::ptr::null()),
120            description: self
121                .description
122                .map(leak_cstring)
123                .unwrap_or(std::ptr::null()),
124        }
125    }
126}
127
128/// Leak a `CString` into `'static` storage. Matches the plugin contract
129/// that `cfmp_metadata` returns a pointer valid for the plugin's lifetime.
130fn leak_cstring(s: CString) -> *const c_char {
131    let ptr = s.as_ptr();
132    std::mem::forget(s);
133    ptr
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use std::ffi::CStr;
140
141    #[test]
142    fn builder_yields_nul_terminated_strings() {
143        let md = PluginMetadataBuilder::new()
144            .name("mock-hash")
145            .version("0.1.0")
146            .vendor("confium")
147            .license("BSD-2-Clause")
148            .description("deterministic mock hash for tests")
149            .build();
150
151        unsafe {
152            assert_eq!(CStr::from_ptr(md.name).to_str().unwrap(), "mock-hash");
153            assert_eq!(CStr::from_ptr(md.version).to_str().unwrap(), "0.1.0");
154            assert_eq!(CStr::from_ptr(md.vendor).to_str().unwrap(), "confium");
155            assert_eq!(CStr::from_ptr(md.license).to_str().unwrap(), "BSD-2-Clause");
156            assert_eq!(
157                CStr::from_ptr(md.description).to_str().unwrap(),
158                "deterministic mock hash for tests"
159            );
160        }
161        assert!(md.homepage_url.is_null());
162        assert!(md.source_url.is_null());
163        assert!(md.issue_tracker_url.is_null());
164    }
165
166    #[test]
167    fn empty_builder_yields_all_null() {
168        let md = PluginMetadataBuilder::new().build();
169        assert!(md.name.is_null());
170        assert!(md.version.is_null());
171        assert!(md.vendor.is_null());
172        assert!(md.license.is_null());
173        assert!(md.homepage_url.is_null());
174        assert!(md.source_url.is_null());
175        assert!(md.issue_tracker_url.is_null());
176        assert!(md.description.is_null());
177    }
178}