Skip to main content

cotis_cli/
plugin.rs

1//! Host-side loading and invocation of routine dynamic libraries.
2//!
3//! Routine plugins export a C ABI documented in the [crate root](crate#plugin-abi). This module
4//! resolves symbols, validates API version, and calls `run`, `help`, and optional
5//! `finish_installation` hooks.
6//!
7//! For authoring a plugin, implement the `cotis_plugin_*` exports in your `cdylib` crate.
8//! See **cotis-web-builder** and **cotis-android-builder** for complete examples.
9
10use std::ffi::{CStr, CString, c_char};
11use std::path::Path;
12
13use libloading::{Library, Symbol};
14use serde::{Deserialize, Serialize};
15
16/// Plugin ABI version that [`LoadedPlugin::ensure_compatible`] requires.
17pub const COTIS_PLUGIN_API_VERSION: u32 = 1;
18
19type ApiVersionFn = unsafe extern "C" fn() -> u32;
20type RunFn = unsafe extern "C" fn(argc: i32, argv: *const *const c_char) -> i32;
21type HelpFn = unsafe extern "C" fn() -> *mut c_char;
22type DescriptorFn = unsafe extern "C" fn() -> *mut c_char;
23type FreeStringFn = unsafe extern "C" fn(ptr: *mut c_char);
24type FinishInstallationFn = unsafe extern "C" fn() -> i32;
25
26/// JSON descriptor returned by `cotis_plugin_descriptor_json`.
27///
28/// Written to `descriptor.json` beside the installed plugin library during [`crate::install::install`].
29#[derive(Debug, Clone, Deserialize, Serialize)]
30pub struct PluginDescriptor {
31    /// Routine name (cache directory name and `argv[0]` on `run`).
32    pub name: String,
33    /// Version string reported by the plugin (independent of cache directory version for `path:` installs).
34    pub version: String,
35    /// When true, `cotis-cli install` calls `cotis_plugin_finish_installation` after files are written.
36    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
37    pub finish_installation: bool,
38}
39
40/// A loaded routine dynamic library with resolved C symbols.
41///
42/// Keeps the underlying [`Library`] alive for the lifetime of this value.
43#[derive(Debug)]
44pub struct LoadedPlugin {
45    _lib: Library,
46    api_version: Symbol<'static, ApiVersionFn>,
47    run: Symbol<'static, RunFn>,
48    help: Symbol<'static, HelpFn>,
49    descriptor_json: Symbol<'static, DescriptorFn>,
50    free_string: Symbol<'static, FreeStringFn>,
51    finish_installation: Option<Symbol<'static, FinishInstallationFn>>,
52}
53
54impl LoadedPlugin {
55    /// Load a routine plugin from a dynamic library path.
56    ///
57    /// Resolves required symbols: `cotis_plugin_api_version`, `cotis_plugin_run`,
58    /// `cotis_plugin_help`, `cotis_plugin_descriptor_json`, `cotis_plugin_free_string`.
59    /// Optionally resolves `cotis_plugin_finish_installation`.
60    ///
61    /// # Safety
62    ///
63    /// Loading and executing arbitrary dynamic libraries is inherently unsafe. The caller must
64    /// ensure `path` points to a trusted plugin built for the host ABI. Symbol function pointers
65    /// are transmuted to `'static` lifetimes because this struct owns the [`Library`]; this is a
66    /// standard `libloading` pattern.
67    ///
68    /// # Errors
69    ///
70    /// Returns an error string if the file cannot be loaded or a required symbol is missing.
71    ///
72    /// # Examples
73    ///
74    /// ```ignore
75    /// let plugin = unsafe { LoadedPlugin::load(path)? };
76    /// plugin.ensure_compatible()?;
77    /// ```
78    pub unsafe fn load(path: &Path) -> Result<Self, String> {
79        let lib = unsafe {
80            Library::new(path)
81                .map_err(|e| format!("Failed to load plugin {}: {e}", path.display()))?
82        };
83
84        unsafe fn sym<T>(lib: &Library, name: &[u8]) -> Result<Symbol<'static, T>, String> {
85            let s: Symbol<T> = unsafe {
86                lib.get(name).map_err(|e| {
87                    format!("Missing symbol {:?}: {e}", String::from_utf8_lossy(name))
88                })?
89            };
90            Ok(unsafe { std::mem::transmute::<Symbol<T>, Symbol<'static, T>>(s) })
91        }
92
93        let api_version = unsafe { sym::<ApiVersionFn>(&lib, b"cotis_plugin_api_version\0")? };
94        let run = unsafe { sym::<RunFn>(&lib, b"cotis_plugin_run\0")? };
95        let help = unsafe { sym::<HelpFn>(&lib, b"cotis_plugin_help\0")? };
96        let descriptor_json =
97            unsafe { sym::<DescriptorFn>(&lib, b"cotis_plugin_descriptor_json\0")? };
98        let free_string = unsafe { sym::<FreeStringFn>(&lib, b"cotis_plugin_free_string\0")? };
99        let finish_installation = unsafe {
100            lib.get::<FinishInstallationFn>(b"cotis_plugin_finish_installation\0")
101                .ok()
102                .map(|s| {
103                    std::mem::transmute::<
104                        Symbol<FinishInstallationFn>,
105                        Symbol<'static, FinishInstallationFn>,
106                    >(s)
107                })
108        };
109
110        Ok(Self {
111            _lib: lib,
112            api_version,
113            run,
114            help,
115            descriptor_json,
116            free_string,
117            finish_installation,
118        })
119    }
120
121    /// Verify the plugin's `cotis_plugin_api_version` matches [`COTIS_PLUGIN_API_VERSION`].
122    ///
123    /// # Errors
124    ///
125    /// Returns an error if the plugin reports a different API version.
126    pub fn ensure_compatible(&self) -> Result<(), String> {
127        let v = unsafe { (self.api_version)() };
128        if v != COTIS_PLUGIN_API_VERSION {
129            return Err(format!(
130                "Incompatible plugin API version: plugin={v}, host={}",
131                COTIS_PLUGIN_API_VERSION
132            ));
133        }
134        Ok(())
135    }
136
137    /// Return help text from `cotis_plugin_help`.
138    ///
139    /// # Errors
140    ///
141    /// Returns an error if the plugin returns null, non-UTF-8 data, or allocation fails.
142    pub fn help(&self) -> Result<String, String> {
143        unsafe { self.get_owned_string(self.help.clone()) }
144    }
145
146    /// Return raw descriptor JSON from `cotis_plugin_descriptor_json`.
147    ///
148    /// # Errors
149    ///
150    /// Returns an error if the plugin returns null, non-UTF-8 data, or allocation fails.
151    pub fn descriptor_json(&self) -> Result<String, String> {
152        unsafe { self.get_owned_string(self.descriptor_json.clone()) }
153    }
154
155    /// Parse and return the plugin descriptor.
156    ///
157    /// # Errors
158    ///
159    /// Returns an error if [`Self::descriptor_json`] fails or JSON does not match [`PluginDescriptor`].
160    pub fn descriptor(&self) -> Result<PluginDescriptor, String> {
161        let json = self.descriptor_json()?;
162        serde_json::from_str(&json).map_err(|e| format!("Failed to parse descriptor JSON: {e}"))
163    }
164
165    /// Invoke `cotis_plugin_run` with the given argument vector.
166    ///
167    /// On `cotis-cli run`, `args[0]` is the routine name; remaining entries are passthrough args.
168    ///
169    /// # Errors
170    ///
171    /// Returns an error if any argument contains a NUL byte.
172    ///
173    /// Returns `Ok(rc)` with the plugin's exit code (`0` = success).
174    pub fn run_with_args(&self, args: &[String]) -> Result<i32, String> {
175        let cstrs: Vec<CString> = args
176            .iter()
177            .map(|s| {
178                CString::new(s.as_str()).map_err(|_| "Argument contained NUL byte".to_string())
179            })
180            .collect::<Result<_, _>>()?;
181        let ptrs: Vec<*const c_char> = cstrs.iter().map(|s| s.as_ptr()).collect();
182        let rc = unsafe { (self.run)(ptrs.len() as i32, ptrs.as_ptr()) };
183        Ok(rc)
184    }
185
186    /// If the descriptor opts in (`finish_installation: true`), runs `cotis_plugin_finish_installation`.
187    ///
188    /// The host sets `COTIS_CLI_CACHE_DIR` (and `COTIS_CLI_INSTALL_PROJECT_DIR` for `path:` installs)
189    /// before calling this. See [`crate::install`].
190    ///
191    /// # Errors
192    ///
193    /// Returns an error if `finish_installation` is true but the symbol is missing, or if the
194    /// hook returns a non-zero exit code.
195    pub fn finish_installation_if_requested(&self, desc: &PluginDescriptor) -> Result<(), String> {
196        if !desc.finish_installation {
197            return Ok(());
198        }
199        let Some(fin) = &self.finish_installation else {
200            return Err(
201                "Plugin descriptor sets \"finish_installation\": true but the library does not export cotis_plugin_finish_installation"
202                    .to_string(),
203            );
204        };
205        let rc = unsafe { (fin)() };
206        if rc != 0 {
207            return Err(format!(
208                "cotis_plugin_finish_installation returned non-zero exit code: {rc}"
209            ));
210        }
211        Ok(())
212    }
213
214    unsafe fn get_owned_string(
215        &self,
216        f: Symbol<'static, unsafe extern "C" fn() -> *mut c_char>,
217    ) -> Result<String, String> {
218        let ptr = unsafe { (f)() };
219        if ptr.is_null() {
220            return Err("Plugin returned null string".to_string());
221        }
222        let s = unsafe {
223            CStr::from_ptr(ptr)
224                .to_str()
225                .map_err(|_| "Plugin returned non-utf8 string".to_string())?
226                .to_string()
227        };
228        unsafe {
229            (self.free_string)(ptr);
230        }
231        Ok(s)
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::PluginDescriptor;
238
239    #[test]
240    fn plugin_descriptor_round_trip_without_finish_installation() {
241        let json = r#"{"name":"my_routine","version":"0.1.0"}"#;
242        let desc: PluginDescriptor = serde_json::from_str(json).unwrap();
243        assert_eq!(desc.name, "my_routine");
244        assert_eq!(desc.version, "0.1.0");
245        assert!(!desc.finish_installation);
246        assert_eq!(serde_json::to_string(&desc).unwrap(), json);
247    }
248
249    #[test]
250    fn plugin_descriptor_round_trip_with_finish_installation() {
251        let json = r#"{"name":"my_routine","version":"0.1.0","finish_installation":true}"#;
252        let desc: PluginDescriptor = serde_json::from_str(json).unwrap();
253        assert!(desc.finish_installation);
254        assert_eq!(serde_json::to_string(&desc).unwrap(), json);
255    }
256}