Skip to main content

confium_publish/
load.rs

1// Load a built plugin artifact and query it via the FFI contract.
2//
3// `confium-publish` is a standalone tool: it does NOT spin up a full
4// `Confium` runtime (that would require a configured trust store, loaded
5// providers, etc.). Instead it opens the artifact as a plain dynamic
6// library with `libloading` and calls the C-ABI entry points directly.
7//
8// Two symbols are consulted, both optional per the plugin contract
9//
10//   * `cfmp_metadata`           -> `*const CFMPluginMetadata` (or NULL)
11//   * `cfmp_query_interfaces`   -> packed `name\0ver\0...` byte stream
12//
13// When a symbol is absent the caller falls back to the matching CLI
14// override (`--name`, `--version`, `--interfaces`, ...).
15
16use std::collections::BTreeMap;
17use std::ffi::CStr;
18use std::os::raw::c_char;
19use std::path::Path;
20
21use libloading::Library;
22use snafu::{ResultExt, Snafu};
23
24use crate::cli::{parse_algorithm_overrides, parse_interface_overrides};
25
26/// C-ABI mirror of the plugin metadata struct declared in
27/// a plugin may leave any of them NULL.
28#[repr(C)]
29pub struct CFMPluginMetadata {
30    pub name: *const c_char,
31    pub version: *const c_char,
32    pub vendor: *const c_char,
33    pub license: *const c_char,
34    pub homepage: *const c_char,
35    pub description: *const c_char,
36    pub homepage_url: *const c_char,
37    pub source_url: *const c_char,
38    pub issue_tracker_url: *const c_char,
39}
40
41/// Rust-friendly, owned copy of the metadata. Every field is `Option`
42/// because the plugin may omit any of them; the caller fills gaps from
43/// CLI args.
44#[derive(Debug, Default, Clone, PartialEq, Eq)]
45pub struct PluginMetadata {
46    pub name: Option<String>,
47    pub version: Option<String>,
48    pub vendor: Option<String>,
49    pub license: Option<String>,
50    pub homepage: Option<String>,
51    pub description: Option<String>,
52    pub source_url: Option<String>,
53}
54
55/// A single advertised interface: its wire name and the version byte
56/// the plugin speaks.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct InterfaceEntry {
59    pub name: String,
60    pub version: u8,
61}
62
63#[derive(Snafu, Debug)]
64pub enum LoadError {
65    #[snafu(display("failed to open artifact at '{}'", path.display()))]
66    OpenLibrary {
67        path: Box<Path>,
68        source: libloading::Error,
69    },
70
71    #[snafu(display("invalid UTF-8 in FFI payload"))]
72    InvalidUtf8 { source: std::str::Utf8Error },
73
74    #[snafu(display("invalid CLI override: {}", message))]
75    InvalidOverride { message: String },
76}
77
78impl LoadError {
79    fn from_override(message: String) -> Self {
80        Self::InvalidOverride { message }
81    }
82}
83
84pub type Result<T> = std::result::Result<T, LoadError>;
85
86/// Open the artifact as a dynamic library. The `Library` handle owns the
87/// mapping; keep it alive for as long as any borrowed FFI data is in use.
88pub fn open_library(path: &Path) -> Result<Library> {
89    let path_boxed: Box<Path> = Box::from(path);
90    unsafe { Library::new(path_boxed.as_ref()) }.context(OpenLibrarySnafu { path: path_boxed })
91}
92
93/// Call `cfmp_metadata()` if exported, returning an owned copy. Returns
94/// `Ok(None)` when the symbol is absent (the plugin is still loadable by
95/// Confium but ineligible for registry publishing per the contract).
96pub fn query_metadata(lib: &Library) -> Result<Option<PluginMetadata>> {
97    let Ok(sym) =
98        (unsafe { lib.get::<extern "C" fn() -> *const CFMPluginMetadata>(b"cfmp_metadata\0") })
99    else {
100        return Ok(None);
101    };
102    let raw_ptr = sym();
103    if raw_ptr.is_null() {
104        return Ok(None);
105    }
106    // SAFETY: the plugin vouched for the pointer by returning non-NULL.
107    // We copy every string out immediately and never hold the raw struct
108    // across an FFI boundary.
109    let raw = unsafe { &*raw_ptr };
110    Ok(Some(PluginMetadata {
111        name: cstr_to_string(raw.name),
112        version: cstr_to_string(raw.version),
113        vendor: cstr_to_string(raw.vendor),
114        license: cstr_to_string(raw.license),
115        homepage: cstr_to_string(raw.homepage),
116        description: cstr_to_string(raw.description),
117        source_url: cstr_to_string(raw.source_url),
118    }))
119}
120
121/// Call `cfmp_query_interfaces()` if exported, parsing the packed
122/// `name\0ver\0` stream. Returns `Ok(None)` when the symbol is absent.
123///
124/// The v0 contract signature takes a `*mut Confium`, which we do not
125/// have in the standalone publish tool. Plugins that need the handle to
126/// answer should be queried at runtime instead; for publishing we pass a
127/// null pointer and rely on well-behaved plugins that can enumerate
128/// without a handle. When that is not possible the caller supplies
129/// `--interfaces` on the command line.
130pub fn query_interfaces(lib: &Library) -> Result<Option<Vec<InterfaceEntry>>> {
131    let Ok(sym) = (unsafe {
132        lib.get::<extern "C" fn(*mut std::ffi::c_void) -> *const u8>(b"cfmp_query_interfaces\0")
133    }) else {
134        return Ok(None);
135    };
136    let ptr = sym(std::ptr::null_mut());
137    if ptr.is_null() {
138        return Ok(Some(Vec::new()));
139    }
140    Ok(Some(parse_interface_stream(ptr)?))
141}
142
143/// Parse the packed `name\0version_byte\0` stream terminated by an empty
144/// name, mirroring `confium-core`'s `enumerate_plugin_interfaces`.
145fn parse_interface_stream(start: *const u8) -> Result<Vec<InterfaceEntry>> {
146    let mut out = Vec::new();
147    let mut idx = 0usize;
148    loop {
149        let name_start = idx;
150        let mut name_end = name_start;
151        // SAFETY: we read bytes one at a time, stopping at the NUL that
152        // terminates each name. The stream is guaranteed NUL-terminated
153        // by the contract; a missing terminator is UB on the plugin's
154        // side, not ours.
155        while unsafe { *start.add(name_end) } != 0 {
156            name_end += 1;
157        }
158        let bytes =
159            unsafe { std::slice::from_raw_parts(start.add(name_start), name_end - name_start) };
160        let name = std::str::from_utf8(bytes).context(InvalidUtf8Snafu)?;
161        if name.is_empty() {
162            break;
163        }
164        let version = unsafe { *start.add(name_end + 1) };
165        out.push(InterfaceEntry {
166            name: name.to_string(),
167            version,
168        });
169        idx = name_end + 2;
170    }
171    Ok(out)
172}
173
174fn cstr_to_string(ptr: *const c_char) -> Option<String> {
175    if ptr.is_null() {
176        return None;
177    }
178    // SAFETY: the plugin promises a NUL-terminated UTF-8 string when the
179    // pointer is non-NULL.
180    let bytes = unsafe { CStr::from_ptr(ptr) }.to_bytes();
181    std::str::from_utf8(bytes).ok().map(str::to_string)
182}
183
184/// Resolve the effective `[interfaces]` map: CLI override wins, else the
185/// FFI query, else empty. Returns a `BTreeMap` so the serialized TOML is
186/// deterministic.
187pub fn resolve_interfaces(
188    ffi: Option<&[InterfaceEntry]>,
189    cli_override: Option<&[String]>,
190) -> Result<BTreeMap<String, u8>> {
191    if let Some(raw) = cli_override {
192        let parsed = parse_interface_overrides(raw).map_err(LoadError::from_override)?;
193        let mut map = BTreeMap::new();
194        for (name, ver) in parsed {
195            map.insert(name, ver);
196        }
197        return Ok(map);
198    }
199    let mut map: BTreeMap<String, u8> = BTreeMap::new();
200    if let Some(entries) = ffi {
201        for entry in entries {
202            map.entry(entry.name.clone())
203                .and_modify(|v: &mut u8| *v = (*v).max(entry.version))
204                .or_insert(entry.version);
205        }
206    }
207    Ok(map)
208}
209
210/// Resolve the effective `[algorithms]` map: CLI override wins, else
211/// empty (FFI does not advertise algorithms today).
212pub fn resolve_algorithms(
213    cli_override: Option<&[String]>,
214) -> Result<BTreeMap<String, Vec<String>>> {
215    let mut map = BTreeMap::new();
216    if let Some(raw) = cli_override {
217        let parsed = parse_algorithm_overrides(raw).map_err(LoadError::from_override)?;
218        for (iface, algos) in parsed {
219            map.insert(iface, algos);
220        }
221    }
222    Ok(map)
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn resolve_interfaces_prefers_cli_override() {
231        let ffi = vec![
232            InterfaceEntry {
233                name: "hash".into(),
234                version: 0,
235            },
236            InterfaceEntry {
237                name: "rng".into(),
238                version: 0,
239            },
240        ];
241        let cli = vec!["aead:1".to_string()];
242        let got = resolve_interfaces(Some(&ffi), Some(&cli)).unwrap();
243        // Only the CLI entry survives.
244        assert_eq!(got.len(), 1);
245        assert_eq!(got["aead"], 1);
246    }
247
248    #[test]
249    fn resolve_interfaces_falls_back_to_ffi() {
250        let ffi = vec![InterfaceEntry {
251            name: "hash".into(),
252            version: 0,
253        }];
254        let got = resolve_interfaces(Some(&ffi), None).unwrap();
255        assert_eq!(got["hash"], 0);
256    }
257
258    #[test]
259    fn resolve_interfaces_empty_when_no_source() {
260        let got = resolve_interfaces(None, None).unwrap();
261        assert!(got.is_empty());
262    }
263
264    #[test]
265    fn resolve_algorithms_parses_cli() {
266        let cli = vec!["hash:SHA-256;SHA-512".to_string()];
267        let got = resolve_algorithms(Some(&cli)).unwrap();
268        assert_eq!(
269            got["hash"],
270            vec!["SHA-256".to_string(), "SHA-512".to_string()]
271        );
272    }
273}