Skip to main content

pmpx_loader/
plugin.rs

1//! Loading one plugin and calling it.
2
3use std::ffi::OsString;
4use std::path::{Path, PathBuf};
5
6use libloading::{Library, Symbol};
7use pmpx_plugin_abi::{
8    PmpxCommand, PmpxCommandCap, PmpxHost, PmpxIdentity, PmpxPlugin, PmpxStr, PMPX_ABI_MAJOR,
9    PMPX_CAP_ATTACH, PMPX_CAP_COMMAND, PMPX_CAP_IDENTITY, PMPX_ENTRY_SYMBOL, PMPX_ERR_INTERNAL,
10    PMPX_ERR_INVALID_ARGS, PMPX_ERR_UNSUPPORTED_VERB, PMPX_OK,
11};
12
13use crate::context::ContextSource;
14use crate::error::{CallError, LoadError};
15
16/// One command a plugin answered with.
17///
18/// The plugin produced it; this crate copied every byte out of the plugin's memory before the plugin
19/// released it, so what is here belongs to the host.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Command {
22    /// The program to run. Empty means the plugin answered success without naming one, which the
23    /// engine reports -- this layer only copies what is there.
24    pub program: OsString,
25    /// Its arguments, verbatim.
26    pub args: Vec<OsString>,
27    /// Where to run it, or `None` for "the project root".
28    pub cwd: Option<PathBuf>,
29}
30
31/// The tables of one plugin, checked and ready to call.
32///
33/// Split from [`Plugin`] on purpose: everything interesting -- negotiation, the identity, calling --
34/// works on the tables alone, and only the *loading* needs a library handle. That is what lets a
35/// host that embeds a plugin in the same process (a test, a harness) use this directly, and it is
36/// what its own tests exercise without a `dlopen`.
37pub struct Tables {
38    root: *const PmpxPlugin,
39    command: *const PmpxCommandCap,
40    name: String,
41    family: String,
42}
43
44impl Tables {
45    /// Check what one plugin speaks and read its identity.
46    ///
47    /// # Safety
48    ///
49    /// `root` must point at a valid [`PmpxPlugin`] whose tables and functions stay valid for as long
50    /// as this value is used -- the plugin's `'static` table, or one kept alive by a [`Plugin`].
51    pub unsafe fn from_root(root: *const PmpxPlugin, path: &Path) -> Result<Self, LoadError> {
52        let negotiated = unsafe { negotiate(root, path) }?;
53
54        Ok(Self {
55            root,
56            command: negotiated.command,
57            name: negotiated.name,
58            family: negotiated.family,
59        })
60    }
61
62    /// The name the plugin reports for itself. The caller still has to check it against the
63    /// manifest: a mismatch means the wrong thing was installed.
64    pub fn name(&self) -> &str {
65        &self.name
66    }
67
68    /// The ecosystem family the plugin reports for itself.
69    pub fn family(&self) -> &str {
70        &self.family
71    }
72
73    /// The rustc version and target the plugin was built with. Diagnostics only.
74    pub fn build_info(&self) -> (String, String) {
75        // These two are `'static` in the plugin and are never freed.
76        let read = |value: PmpxStr| {
77            // SAFETY: the plugin's own `'static` bytes.
78            let bytes = unsafe { value.as_bytes() }.unwrap_or(&[]);
79            String::from_utf8_lossy(bytes).into_owned()
80        };
81
82        // SAFETY: `root` is valid for as long as this value is.
83        let root = unsafe { &*self.root };
84        (read(root.rustc_version), read(root.target))
85    }
86
87    /// Offer the host's hooks to the plugin, if it can take them.
88    ///
89    /// Called once after loading, before any call. A plugin without the `attach` capability simply
90    /// does not get them: logging is optional, being called is not.
91    pub fn attach(&self, host: &'static PmpxHost) {
92        let Some(table) = (unsafe { capability(self.root, PMPX_CAP_ATTACH) }) else {
93            return;
94        };
95        let attach: *const pmpx_plugin_abi::PmpxAttach = table.cast();
96        // SAFETY: a non-null table from `capability`; the plugin promises it stays valid.
97        unsafe { ((*attach).attach)(host) };
98    }
99
100    /// Ask the plugin to map one call to one command.
101    pub fn call(&self, source: &ContextSource<'_>) -> Result<Command, CallError> {
102        call_table(self.command, source)
103    }
104}
105
106/// A loaded plugin: the library stays open, and every function pointer in its tables belongs to it.
107pub struct Plugin {
108    /// Held so the library is not unloaded while its function pointers are in use.
109    _library: Library,
110    tables: Tables,
111}
112
113impl Plugin {
114    /// Open a library, check what it speaks, and read its identity.
115    ///
116    /// # Safety
117    ///
118    /// Loading a dynamic library runs whatever code is inside it, including its initializers. The
119    /// caller must trust the file -- which in practice means it came from the plugin store, not from
120    /// an untrusted download.
121    pub unsafe fn open(path: &Path) -> Result<Self, LoadError> {
122        // SAFETY: the caller vouches for the library.
123        let library = unsafe { Library::new(path) }.map_err(|error| LoadError::Open {
124            path: path.to_path_buf(),
125            message: error.to_string(),
126        })?;
127
128        // The symbol carries the root structure's layout, so an old host looking for its own name
129        // finds nothing instead of reading fields that moved.
130        let root = {
131            let mut symbol_name = PMPX_ENTRY_SYMBOL.as_bytes().to_vec();
132            symbol_name.push(0);
133
134            let entry: Symbol<unsafe extern "C" fn() -> *const PmpxPlugin> =
135                // SAFETY: the symbol name is NUL-terminated and the type is the ABI's entry point.
136                unsafe { library.get(symbol_name.as_slice()) }.map_err(|_| LoadError::NoEntrySymbol {
137                    path: path.to_path_buf(),
138                    symbol: PMPX_ENTRY_SYMBOL,
139                })?;
140
141            // SAFETY: the entry point returns the plugin's `'static` table.
142            unsafe { entry() }
143        };
144
145        let tables = unsafe { Tables::from_root(root, path) }?;
146
147        Ok(Self {
148            _library: library,
149            tables,
150        })
151    }
152
153    /// The tables, for everything that does not need the library handle.
154    pub fn tables(&self) -> &Tables {
155        &self.tables
156    }
157
158    /// The name the plugin reports for itself. Shorthand for `self.tables().name()`.
159    pub fn name(&self) -> &str {
160        self.tables.name()
161    }
162
163    /// The ecosystem family the plugin reports for itself.
164    pub fn family(&self) -> &str {
165        self.tables.family()
166    }
167}
168
169/// Call one `command` table.
170///
171/// Separate from [`Plugin`] because the call itself does not need the library handle -- only the
172/// table -- and that is what lets the whole path be tested without a `dlopen`.
173fn call_table(
174    table: *const PmpxCommandCap,
175    source: &ContextSource<'_>,
176) -> Result<Command, CallError> {
177    source.with_context(|context| {
178        let mut out = PmpxCommand::empty();
179
180        // SAFETY: `context` was built by `with_context` and is valid for this call; `out` is
181        // writable local memory; the table was checked when the plugin was loaded.
182        let code = unsafe { ((*table).run)(context, &mut out) };
183
184        if code != PMPX_OK {
185            return Err(code_to_error(code));
186        }
187
188        // Read everything out of the plugin's memory first, then let the plugin release it.
189        let answer = unsafe { read_command(&out) };
190        // SAFETY: `out` was filled in by one successful call to this plugin's `run`.
191        unsafe { ((*table).free_command)(&mut out) };
192        answer
193    })
194}
195
196/// What `negotiate` found: the table this host calls, and the identity it read out of the plugin.
197#[derive(Debug)]
198pub(crate) struct Negotiated {
199    pub(crate) command: *const PmpxCommandCap,
200    pub(crate) name: String,
201    pub(crate) family: String,
202}
203
204/// Check that a plugin speaks this ABI, and fetch the two tables a host cannot work without.
205///
206/// # Safety
207///
208/// `root` must point at a valid [`PmpxPlugin`] whose tables and functions stay valid for as long as
209/// they are used.
210pub(crate) unsafe fn negotiate(
211    root: *const PmpxPlugin,
212    path: &Path,
213) -> Result<Negotiated, LoadError> {
214    if root.is_null() {
215        return Err(LoadError::NoEntrySymbol {
216            path: path.to_path_buf(),
217            symbol: PMPX_ENTRY_SYMBOL,
218        });
219    }
220
221    // SAFETY: non-null, and the caller vouches for the table.
222    let found = unsafe { (*root).abi_major };
223    if found != PMPX_ABI_MAJOR {
224        return Err(LoadError::Major {
225            path: path.to_path_buf(),
226            found,
227            expected: PMPX_ABI_MAJOR,
228        });
229    }
230
231    let identity = unsafe { required_table::<PmpxIdentity>(root, PMPX_CAP_IDENTITY, path) }?;
232    let command = unsafe { required_table::<PmpxCommandCap>(root, PMPX_CAP_COMMAND, path) }?;
233
234    // The identity's two strings belong to the plugin and are released straight after copying.
235    // SAFETY: the table is valid, and `free_str` is the plugin's own release function.
236    let name = unsafe { read_owned_str((*identity).name, (*identity).free_str)? };
237    // SAFETY: as above.
238    let family = unsafe { read_owned_str((*identity).family, (*identity).free_str)? };
239
240    Ok(Negotiated {
241        command,
242        name,
243        family,
244    })
245}
246
247/// The table for one required capability, checked for presence and size.
248///
249/// # Safety
250///
251/// See [`negotiate`].
252unsafe fn required_table<T>(
253    root: *const PmpxPlugin,
254    capability_name: &'static str,
255    path: &Path,
256) -> Result<*const T, LoadError> {
257    let Some(table) = (unsafe { capability(root, capability_name) }) else {
258        return Err(LoadError::MissingCapability {
259            path: path.to_path_buf(),
260            capability: capability_name,
261        });
262    };
263
264    let table: *const T = table.cast();
265    // Every table starts with its own size, so a plugin built against an *older*, smaller table is
266    // refused instead of being read past its end.
267    let found = unsafe { *table.cast::<usize>() };
268    let expected = std::mem::size_of::<T>();
269    if found < expected {
270        return Err(LoadError::ShortTable {
271            path: path.to_path_buf(),
272            capability: capability_name,
273            found,
274            expected,
275        });
276    }
277
278    Ok(table)
279}
280
281/// Ask a plugin for one capability table.
282///
283/// # Safety
284///
285/// `root` must be a valid plugin table.
286unsafe fn capability(root: *const PmpxPlugin, name: &str) -> Option<*const std::ffi::c_void> {
287    let lookup = unsafe { (*root).capability };
288    let key = PmpxStr::new(name.as_ptr(), name.len());
289    // SAFETY: the plugin's lookup is called with a borrow that outlives the call, and it returns
290    // either null or a pointer to one of its own tables.
291    let table = unsafe { lookup(key) };
292    if table.is_null() {
293        None
294    } else {
295        Some(table)
296    }
297}
298
299/// Call one `-> PmpxStr` function and copy the answer before releasing it.
300///
301/// # Safety
302///
303/// `f` and `free` must come from the same plugin, and `free` must be that plugin's release function.
304unsafe fn read_owned_str(
305    f: unsafe extern "C" fn() -> PmpxStr,
306    free: unsafe extern "C" fn(PmpxStr),
307) -> Result<String, LoadError> {
308    // SAFETY: the caller vouches for the function.
309    let value = unsafe { f() };
310    let bytes = unsafe { value.as_bytes() }.unwrap_or(&[]);
311    let text = String::from_utf8_lossy(bytes).into_owned();
312    // The memory belongs to the plugin: it is copied above and released here, once.
313    unsafe { free(value) };
314    Ok(text)
315}
316
317/// Copy one command out of the plugin's memory.
318///
319/// # Safety
320///
321/// `command` must have been filled in by a successful `run`, and its strings must be valid.
322unsafe fn read_command(command: &PmpxCommand) -> Result<Command, CallError> {
323    let expected = std::mem::size_of::<PmpxCommand>();
324    if command.size < expected {
325        return Err(CallError::ShortCommand {
326            found: command.size,
327            expected,
328        });
329    }
330
331    // SAFETY: the fields are past the size the plugin declared, and the plugin filled them in.
332    let program = unsafe { command.program.as_bytes() }.unwrap_or(&[]);
333    // SAFETY: as above; a null array means "no arguments".
334    let args = unsafe { command.args.as_slice() }.unwrap_or(&[]);
335    // SAFETY: as above.
336    let cwd = unsafe { command.cwd.as_bytes() };
337
338    Ok(Command {
339        program: pmpx_plugin_abi::bytes_to_os(program),
340        args: args
341            .iter()
342            .map(|arg| {
343                // SAFETY: every element is a view the plugin filled in.
344                let bytes = unsafe { arg.as_bytes() }.unwrap_or(&[]);
345                pmpx_plugin_abi::bytes_to_os(bytes)
346            })
347            .collect(),
348        cwd: cwd.map(|bytes| PathBuf::from(pmpx_plugin_abi::bytes_to_os(bytes))),
349    })
350}
351
352/// Translate one answer code.
353///
354/// An unknown code is kept as [`CallError::Unknown`] rather than guessed: the plugin may be newer
355/// than this host, and the engine decides how loud to be about it.
356fn code_to_error(code: u32) -> CallError {
357    match code {
358        PMPX_ERR_UNSUPPORTED_VERB => CallError::UnsupportedVerb,
359        PMPX_ERR_INVALID_ARGS => CallError::InvalidArgs,
360        PMPX_ERR_INTERNAL => CallError::Internal,
361        other => CallError::Unknown(other),
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use pmpx_plugin_abi::PmpxContext;
368
369    use super::*;
370
371    /// An identity table that answers with borrowed `'static` strings.
372    static NAME: &[u8] = b"toy";
373    static FAMILY: &[u8] = b"node";
374
375    unsafe extern "C" fn toy_name() -> PmpxStr {
376        PmpxStr::new(NAME.as_ptr(), NAME.len())
377    }
378
379    unsafe extern "C" fn toy_family() -> PmpxStr {
380        PmpxStr::new(FAMILY.as_ptr(), FAMILY.len())
381    }
382
383    unsafe extern "C" fn toy_free_str(_s: PmpxStr) {
384        // The fixture's strings are `'static`: nothing to release.
385    }
386
387    /// A command table whose `run` fills the out struct with plugin-owned memory.
388    static COMMAND_TEXT: &[u8] = b"pnpm";
389    static ARG_TEXT: &[u8] = b"install";
390
391    unsafe extern "C" fn toy_run(_context: *const PmpxContext, out: *mut PmpxCommand) -> u32 {
392        let args = vec![PmpxStr::new(ARG_TEXT.as_ptr(), ARG_TEXT.len())].into_boxed_slice();
393        let len = args.len();
394        let ptr = Box::into_raw(args) as *const PmpxStr;
395
396        // SAFETY: the host promises a writable command.
397        unsafe {
398            (*out).program = PmpxStr::new(COMMAND_TEXT.as_ptr(), COMMAND_TEXT.len());
399            (*out).args = pmpx_plugin_abi::PmpxSlice::new(ptr, len);
400            (*out).cwd = PmpxStr::EMPTY;
401        }
402        PMPX_OK
403    }
404
405    unsafe extern "C" fn toy_free_command(command: *mut PmpxCommand) {
406        // SAFETY: `run` leaked exactly this box, and the host frees once.
407        let args = unsafe { &*command }.args;
408        if !args.is_absent() {
409            let raw = std::ptr::slice_from_raw_parts_mut(args.ptr as *mut PmpxStr, args.len);
410            drop(unsafe { Box::from_raw(raw) });
411        }
412    }
413
414    static IDENTITY: PmpxIdentity = PmpxIdentity {
415        size: std::mem::size_of::<PmpxIdentity>(),
416        name: toy_name,
417        family: toy_family,
418        free_str: toy_free_str,
419    };
420
421    static COMMAND: PmpxCommandCap = PmpxCommandCap {
422        size: std::mem::size_of::<PmpxCommandCap>(),
423        run: toy_run,
424        free_command: toy_free_command,
425    };
426
427    unsafe extern "C" fn lookup(name: PmpxStr) -> *const std::ffi::c_void {
428        let bytes = unsafe { name.as_bytes() }.unwrap_or(&[]);
429        match bytes {
430            b"identity" => std::ptr::from_ref(&IDENTITY).cast(),
431            b"command" => std::ptr::from_ref(&COMMAND).cast(),
432            _ => std::ptr::null(),
433        }
434    }
435
436    fn plugin_table(major: u32) -> PmpxPlugin {
437        PmpxPlugin {
438            abi_major: major,
439            rustc_version: PmpxStr::EMPTY,
440            target: PmpxStr::EMPTY,
441            capability: lookup,
442        }
443    }
444
445    #[test]
446    fn a_plugin_that_speaks_this_abi_is_accepted() {
447        let table = plugin_table(PMPX_ABI_MAJOR);
448        let negotiated =
449            unsafe { negotiate(&table, Path::new("/x.so")) }.expect("should negotiate");
450
451        assert_eq!(negotiated.name, "toy");
452        assert_eq!(negotiated.family, "node");
453    }
454
455    #[test]
456    fn a_plugin_from_another_major_is_refused() {
457        let table = plugin_table(PMPX_ABI_MAJOR + 1);
458        let error = unsafe { negotiate(&table, Path::new("/x.so")) }.unwrap_err();
459
460        match error {
461            LoadError::Major {
462                found, expected, ..
463            } => {
464                assert_eq!(found, PMPX_ABI_MAJOR + 1);
465                assert_eq!(expected, PMPX_ABI_MAJOR);
466            }
467            other => panic!("expected a major-version refusal, got {other:?}"),
468        }
469    }
470
471    /// A capability the host needs but the plugin does not have: refused, and named.
472    #[test]
473    fn a_missing_required_capability_is_refused_by_name() {
474        unsafe extern "C" fn only_identity(name: PmpxStr) -> *const std::ffi::c_void {
475            let bytes = unsafe { name.as_bytes() }.unwrap_or(&[]);
476            match bytes {
477                b"identity" => std::ptr::from_ref(&IDENTITY).cast(),
478                _ => std::ptr::null(),
479            }
480        }
481
482        // A local, not a `static`: a table of raw pointers is not `Sync`, and there is no reason for
483        // it to outlive this test.
484        let table = PmpxPlugin {
485            abi_major: PMPX_ABI_MAJOR,
486            rustc_version: PmpxStr::EMPTY,
487            target: PmpxStr::EMPTY,
488            capability: only_identity,
489        };
490
491        let error = unsafe { negotiate(&table, Path::new("/x.so")) }.unwrap_err();
492        match error {
493            LoadError::MissingCapability { capability, .. } => assert_eq!(capability, "command"),
494            other => panic!("expected a missing capability, got {other:?}"),
495        }
496    }
497
498    /// A table smaller than this host knows how to read: refused rather than read past its end.
499    #[test]
500    fn a_table_smaller_than_this_host_expects_is_refused() {
501        #[repr(C)]
502        struct ShortTable {
503            size: usize,
504            name: unsafe extern "C" fn() -> PmpxStr,
505        }
506
507        unsafe extern "C" fn short_name() -> PmpxStr {
508            PmpxStr::EMPTY
509        }
510
511        static SHORT: ShortTable = ShortTable {
512            size: std::mem::size_of::<PmpxIdentity>() - 8,
513            name: short_name,
514        };
515
516        unsafe extern "C" fn short_lookup(name: PmpxStr) -> *const std::ffi::c_void {
517            let bytes = unsafe { name.as_bytes() }.unwrap_or(&[]);
518            match bytes {
519                b"identity" => std::ptr::from_ref(&SHORT).cast(),
520                b"command" => std::ptr::from_ref(&COMMAND).cast(),
521                _ => std::ptr::null(),
522            }
523        }
524
525        let table = PmpxPlugin {
526            abi_major: PMPX_ABI_MAJOR,
527            rustc_version: PmpxStr::EMPTY,
528            target: PmpxStr::EMPTY,
529            capability: short_lookup,
530        };
531
532        let error = unsafe { negotiate(&table, Path::new("/x.so")) }.unwrap_err();
533        match error {
534            LoadError::ShortTable {
535                capability,
536                found,
537                expected,
538                ..
539            } => {
540                assert_eq!(capability, "identity");
541                assert!(found < expected);
542            }
543            other => panic!("expected a short-table refusal, got {other:?}"),
544        }
545    }
546
547    /// A call goes out, comes back as Rust values, and the plugin's memory is released exactly once.
548    ///
549    /// No library is involved: the call needs the table, not the handle, which is exactly why the
550    /// two are separate functions.
551    #[test]
552    fn a_call_copies_the_command_and_releases_the_plugins_memory() {
553        let root = PathBuf::from("/work/project");
554        let matched = vec!["package.json".to_string()];
555        let pins = std::collections::BTreeMap::new();
556        let args = vec![OsString::from("left-pad")];
557        let source = ContextSource {
558            root: &root,
559            start_dir: &root,
560            matched: &matched,
561            config_files: &[],
562            pins: &pins,
563            args: &args,
564            verb: pmpx_plugin_abi::PMPX_VERB_INSTALL,
565            reason: pmpx_plugin_abi::PMPX_REASON_SCORED,
566            score: 100,
567            files: &crate::context::NoFiles,
568        };
569
570        let command = call_table(&COMMAND, &source).expect("the fixture always answers ok");
571
572        assert_eq!(command.program, OsString::from("pnpm"));
573        assert_eq!(command.args, vec![OsString::from("install")]);
574        assert_eq!(command.cwd, None);
575    }
576
577    #[test]
578    fn every_answer_code_has_an_error() {
579        assert_eq!(
580            code_to_error(PMPX_ERR_UNSUPPORTED_VERB),
581            CallError::UnsupportedVerb
582        );
583        assert_eq!(code_to_error(PMPX_ERR_INVALID_ARGS), CallError::InvalidArgs);
584        assert_eq!(code_to_error(PMPX_ERR_INTERNAL), CallError::Internal);
585        assert_eq!(code_to_error(99), CallError::Unknown(99));
586        assert_eq!(code_to_error(99).code(), Some(99));
587    }
588
589    #[test]
590    fn errors_say_what_is_wrong() {
591        let error = LoadError::Major {
592            path: PathBuf::from("/x.so"),
593            found: 2,
594            expected: 3,
595        };
596        let text = error.to_string();
597        assert!(text.contains("/x.so"), "{text}");
598        assert!(text.contains('3'), "{text}");
599
600        assert!(CallError::UnsupportedVerb
601            .to_string()
602            .contains("does not support"));
603    }
604}