1use 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#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Command {
22 pub program: OsString,
25 pub args: Vec<OsString>,
27 pub cwd: Option<PathBuf>,
29}
30
31pub struct Tables {
38 root: *const PmpxPlugin,
39 command: *const PmpxCommandCap,
40 name: String,
41 family: String,
42}
43
44impl Tables {
45 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 pub fn name(&self) -> &str {
65 &self.name
66 }
67
68 pub fn family(&self) -> &str {
70 &self.family
71 }
72
73 pub fn build_info(&self) -> (String, String) {
75 let read = |value: PmpxStr| {
77 let bytes = unsafe { value.as_bytes() }.unwrap_or(&[]);
79 String::from_utf8_lossy(bytes).into_owned()
80 };
81
82 let root = unsafe { &*self.root };
84 (read(root.rustc_version), read(root.target))
85 }
86
87 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 unsafe { ((*attach).attach)(host) };
98 }
99
100 pub fn call(&self, source: &ContextSource<'_>) -> Result<Command, CallError> {
102 call_table(self.command, source)
103 }
104}
105
106pub struct Plugin {
108 _library: Library,
110 tables: Tables,
111}
112
113impl Plugin {
114 pub unsafe fn open(path: &Path) -> Result<Self, LoadError> {
122 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 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 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 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 pub fn tables(&self) -> &Tables {
155 &self.tables
156 }
157
158 pub fn name(&self) -> &str {
160 self.tables.name()
161 }
162
163 pub fn family(&self) -> &str {
165 self.tables.family()
166 }
167}
168
169fn 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 let code = unsafe { ((*table).run)(context, &mut out) };
183
184 if code != PMPX_OK {
185 return Err(code_to_error(code));
186 }
187
188 let answer = unsafe { read_command(&out) };
190 unsafe { ((*table).free_command)(&mut out) };
192 answer
193 })
194}
195
196#[derive(Debug)]
198pub(crate) struct Negotiated {
199 pub(crate) command: *const PmpxCommandCap,
200 pub(crate) name: String,
201 pub(crate) family: String,
202}
203
204pub(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 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 let name = unsafe { read_owned_str((*identity).name, (*identity).free_str)? };
237 let family = unsafe { read_owned_str((*identity).family, (*identity).free_str)? };
239
240 Ok(Negotiated {
241 command,
242 name,
243 family,
244 })
245}
246
247unsafe 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 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
281unsafe 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 let table = unsafe { lookup(key) };
292 if table.is_null() {
293 None
294 } else {
295 Some(table)
296 }
297}
298
299unsafe fn read_owned_str(
305 f: unsafe extern "C" fn() -> PmpxStr,
306 free: unsafe extern "C" fn(PmpxStr),
307) -> Result<String, LoadError> {
308 let value = unsafe { f() };
310 let bytes = unsafe { value.as_bytes() }.unwrap_or(&[]);
311 let text = String::from_utf8_lossy(bytes).into_owned();
312 unsafe { free(value) };
314 Ok(text)
315}
316
317unsafe 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 let program = unsafe { command.program.as_bytes() }.unwrap_or(&[]);
333 let args = unsafe { command.args.as_slice() }.unwrap_or(&[]);
335 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 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
352fn 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 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 }
386
387 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 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 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 #[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 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 #[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 #[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}