pmpx_plugin/abi.rs
1//! The wire format across the `dlopen` boundary.
2//!
3//! The host and the plugin are two separately compiled worlds, so data crossing this line can
4//! only be `#[repr(C)]` POD and plain integers: there is no guarantee about which allocator owns
5//! the memory of `String` / `Vec` / `Box`, the layout of `toml::Value` / `anyhow::Error` changes
6//! with dependency patch versions, and nothing fixes which side a trait object's vtable belongs
7//! to. The price is that the two sides share no allocator -- memory is always freed by the side
8//! that allocated it: inputs passed in by the host are read-only, and outputs produced by the
9//! plugin (`PmpxCommand` and the strings inside it, `name` / `family`) are handed back by the
10//! host with [`free_command`] / [`free_str`]. So `free_*` must never use the host's
11//! `Box::from_raw` to adopt memory that came from the plugin.
12
13use std::ffi::{OsStr, OsString};
14use std::path::PathBuf;
15
16use crate::{CommandSpec, Context, PackageManager, Verb};
17
18// ---- Version ----
19
20/// Version of the cross-boundary layout, an independent integer, fully decoupled from the crate
21/// version. Bump it by one only when the shape of [`PmpxPluginV1`] / [`PmpxCommand`] / [`PmpxStr`],
22/// the verb numbering, or the error-code semantics really change. The host uses it as its only
23/// hard check and refuses to load when it does not match.
24pub const ABI_VERSION: u32 = 1;
25
26// ---- Error codes ----
27
28/// Success.
29pub const PMPX_OK: u32 = 0;
30
31/// This backend does not support that verb.
32/// The host treats it specially: `pmpx exec` degrades to passing through verbatim when it sees
33/// this code, while the other verbs report the error as-is, so it must stay separate from
34/// [`PMPX_ERR_INTERNAL`].
35pub const PMPX_ERR_UNSUPPORTED_VERB: u32 = 1;
36
37/// Invalid input -- an unknown verb number, a null `out`, or non-UTF-8 in `matched`.
38pub const PMPX_ERR_INVALID_ARGS: u32 = 2;
39
40/// The plugin failed internally, or it panicked (a panic is caught by [`guard`] and mapped here,
41/// with the details on stderr).
42pub const PMPX_ERR_INTERNAL: u32 = 3;
43
44// ---- Verb numbers ----
45
46/// Number of [`Verb::Install`].
47pub const VERB_INSTALL: u32 = 0;
48/// Number of [`Verb::Remove`].
49pub const VERB_REMOVE: u32 = 1;
50/// Number of [`Verb::Run`].
51pub const VERB_RUN: u32 = 2;
52/// Number of [`Verb::Build`].
53pub const VERB_BUILD: u32 = 3;
54/// Number of [`Verb::Test`].
55pub const VERB_TEST: u32 = 4;
56/// Number of [`Verb::Update`].
57pub const VERB_UPDATE: u32 = 5;
58/// Number of [`Verb::Exec`].
59pub const VERB_EXEC: u32 = 6;
60
61// ---- Data structures ----
62
63/// A cross-boundary string: pointer + length, with no NUL terminator required.
64/// `ptr` / `len` describe raw bytes (possibly a path or a command-line argument) which on Unix
65/// need not be valid UTF-8; UTF-8 is checked only where the data is explicitly required to be
66/// text, and a failure returns [`PMPX_ERR_INVALID_ARGS`] rather than UB.
67/// It carries a length instead of relying on NUL because the pointer returned by `str::as_ptr()`
68/// is not guaranteed to be followed by a NUL.
69#[repr(C)]
70#[derive(Debug, Copy, Clone)]
71pub struct PmpxStr {
72 /// Start address. May be null when `len == 0`.
73 pub ptr: *const u8,
74 /// Length in bytes.
75 pub len: usize,
76}
77
78// SAFETY: `PmpxStr` is "a read-only byte range plus a length"; the only unsafe part of sharing it
79// across threads is that the memory `ptr` points at must still be valid. Both origins of this
80// struct are well defined:
81// - one passed in by the host: valid for the whole call;
82// - one produced by the plugin: points at a `Box<[u8]>` the plugin leaked, valid until
83// `free_str`.
84// Neither is freed or written while it is shared. So marking it `Sync` holds.
85unsafe impl Sync for PmpxStr {}
86
87impl PmpxStr {
88 /// Empty. `len == 0` with a null pointer -- in `cwd` this means "no override".
89 pub const EMPTY: PmpxStr = PmpxStr {
90 ptr: std::ptr::null(),
91 len: 0,
92 };
93
94 /// Build from a `'static` string (`const` so that the `static` vtable of `export!` can be
95 /// filled in at compile time).
96 pub const fn from_static(s: &'static str) -> Self {
97 Self {
98 ptr: s.as_ptr(),
99 len: s.len(),
100 }
101 }
102
103 /// Whether it is empty.
104 pub const fn is_empty(&self) -> bool {
105 self.len == 0
106 }
107}
108
109/// A command description that crosses the boundary. Filled in by the plugin and freed by the
110/// plugin ([`free_command`]); the host only reads it.
111#[repr(C)]
112#[derive(Debug, Copy, Clone)]
113pub struct PmpxCommand {
114 /// Executable.
115 pub program: PmpxStr,
116 /// Argument array, with `args_len` elements.
117 pub args: *const PmpxStr,
118 /// Number of elements in `args`.
119 pub args_len: usize,
120 /// Working-directory override. `len == 0` means use the project root given by the host.
121 pub cwd: PmpxStr,
122}
123
124// SAFETY: Same as `PmpxStr` -- this struct is just "references to read-only bytes plus an array
125// length".
126unsafe impl Sync for PmpxCommand {}
127
128/// The only struct a plugin exports, and it is that table of function pointers; once the host has
129/// obtained it, every interaction goes through these pointers, with no trait object and none of
130/// the UB that comes from converting between vtables.
131#[repr(C)]
132pub struct PmpxPluginV1 {
133 /// Must equal [`ABI_VERSION`]. This is the first field the host compares.
134 pub abi_version: u32,
135
136 /// The rustc version that built this plugin, injected by `pmpx-plugin`'s build.rs.
137 /// Diagnostics only, never a hard check -- plugins built by different rustcs can be loaded
138 /// safely under this C ABI.
139 pub rustc_version: PmpxStr,
140
141 /// The target triple that built this plugin. Also diagnostics only.
142 pub target: PmpxStr,
143
144 /// Plugin name. The memory belongs to the plugin; the host frees it with [`free_str`] after
145 /// reading, and compares it against the name declared in the manifest -- a mismatch means the
146 /// wrong thing was installed.
147 pub name: unsafe extern "C" fn() -> PmpxStr,
148
149 /// Ecosystem family. The memory belongs to the plugin; the host frees it with [`free_str`]
150 /// after reading.
151 pub family: unsafe extern "C" fn() -> PmpxStr,
152
153 /// Translate "verb + arguments" into one command. When it returns [`PMPX_OK`], `out` has been
154 /// filled in and the host calls [`free_command`] when done; otherwise it returns `PMPX_ERR_*`
155 /// and `out` is untouched.
156 ///
157 /// # Safety
158 /// - `project_root` / `matched` / `args` must be allocated by the host, valid and read-only
159 /// for the duration of the call;
160 /// - `out` must point at a writable [`PmpxCommand`];
161 /// - a panic must not cross this boundary: since Rust 1.81, unwinding across `extern "C"`
162 /// aborts the process and the host's `catch_unwind` cannot save it, so `export!` wraps
163 /// everything in `catch_unwind`.
164 pub command: unsafe extern "C" fn(
165 project_root: PmpxStr,
166 matched: *const PmpxStr,
167 matched_len: usize,
168 verb: u32,
169 args: *const PmpxStr,
170 args_len: usize,
171 out: *mut PmpxCommand,
172 ) -> u32,
173
174 /// Free the memory held by the values returned from [`PmpxPluginV1::name`] /
175 /// [`PmpxPluginV1::family`].
176 ///
177 /// # Safety
178 /// `s` must come from the same plugin and may be freed only once.
179 pub free_str: unsafe extern "C" fn(PmpxStr),
180
181 /// Free the memory filled in by [`PmpxPluginV1::command`]'s [`PmpxCommand`], without freeing
182 /// the struct itself (that struct lives on the host side).
183 ///
184 /// # Safety
185 /// `c` must come from one successful `command` call on the same plugin and may be freed only
186 /// once.
187 pub free_command: unsafe extern "C" fn(*mut PmpxCommand),
188}
189
190// SAFETY: This struct is a read-only table filled in from compile-time constants: a few integers,
191// two `'static` byte ranges, and five function pointers. It is never modified after that.
192// Function pointers are `Sync` themselves.
193unsafe impl Sync for PmpxPluginV1 {}
194
195/// Name of the single entry symbol.
196/// The symbol itself is defined inside the plugin by `pmpx_plugin::export!`, not here -- this
197/// crate gets linked into every plugin, and defining the same `#[no_mangle]` symbol itself would
198/// collide with the one `export!` generates. Its shape is
199/// `extern "C" fn() -> *const PmpxPluginV1`.
200pub const ENTRY_SYMBOL: &str = "pmpx_plugin_entry_v1";
201
202// ---- Build info (diagnostics) ----
203
204/// rustc version injected at compile time. `const fn` is deliberate: the `static` vtable that
205/// `export!` generates has to be evaluated at compile time.
206pub const fn build_rustc() -> PmpxStr {
207 PmpxStr::from_static(env!("PMPX_BUILD_RUSTC"))
208}
209
210/// Target triple injected at compile time.
211pub const fn build_target() -> PmpxStr {
212 PmpxStr::from_static(env!("PMPX_BUILD_TARGET"))
213}
214
215// ---- Memory: allocation and freeing ----
216
217/// Leak a byte range into a [`PmpxStr`] for the other side of the boundary to read.
218/// Every string flowing out of the plugin uses the same allocation (`Box<[u8]>`), so [`free_str`]
219/// has exactly one path and cannot end up freeing a `Box<[u8]>` as a `Box<str>`.
220pub fn leak_bytes(bytes: &[u8]) -> PmpxStr {
221 let boxed: Box<[u8]> = bytes.to_vec().into_boxed_slice();
222 let out = PmpxStr {
223 ptr: boxed.as_ptr(),
224 len: boxed.len(),
225 };
226 std::mem::forget(boxed);
227 out
228}
229
230/// The `&str` version of [`leak_bytes`].
231pub fn leak_str(s: &str) -> PmpxStr {
232 leak_bytes(s.as_bytes())
233}
234
235/// Free a [`PmpxStr`] produced by this side's [`leak_bytes`] / [`leak_str`].
236/// A null pointer returns immediately (that is how [`PmpxStr::EMPTY`] is used); length 0 with a
237/// non-null pointer is a legitimate allocation and goes through `Box::from_raw` normally.
238///
239/// # Safety
240/// - `s` must come from this side's `leak_*`, never from an input the host passed in;
241/// - it may be freed only once.
242pub unsafe fn free_str(s: PmpxStr) {
243 if s.ptr.is_null() {
244 return;
245 }
246 let raw = std::ptr::slice_from_raw_parts_mut(s.ptr as *mut u8, s.len);
247 // Strictly paired with the Box<[u8]> in leak_bytes.
248 drop(unsafe { Box::from_raw(raw) });
249}
250
251/// Free the contents of a [`PmpxCommand`] filled in by this side's [`write_command`], without
252/// freeing `c` itself (that struct lives on the host side, usually on the stack).
253/// # Safety
254/// `c` must come from one successful `command` call on this side and may be freed only once.
255pub unsafe fn free_command(c: *mut PmpxCommand) {
256 if c.is_null() {
257 return;
258 }
259 let cmd = unsafe { &*c };
260
261 unsafe { free_str(cmd.program) };
262 unsafe { free_str(cmd.cwd) };
263
264 if !cmd.args.is_null() && cmd.args_len > 0 {
265 // Strictly paired with the Box<[PmpxStr]> in write_command.
266 let raw = std::ptr::slice_from_raw_parts_mut(cmd.args as *mut PmpxStr, cmd.args_len);
267 let args = unsafe { Box::from_raw(raw) };
268 for s in args.iter() {
269 unsafe { free_str(*s) };
270 }
271 }
272}
273
274// ---- Input direction: bytes <-> OsString ----
275
276/// Read the bytes the host passed in as an `OsString`.
277/// On Unix, paths and command-line arguments need not be valid UTF-8, and a `String` can only
278/// convert lossily, which would silently corrupt calls like
279/// `pmpx exec some-tool /latin1/path`; `OsString` keeps the raw bytes losslessly.
280/// On Windows, `OsString` is WTF-8 underneath and unpaired surrogates degrade to lossy
281/// replacement -- that is the platform's boundary.
282///
283/// # Safety
284/// `s` must describe read-only memory that is valid for the duration of this call, or `len == 0`.
285pub unsafe fn read_os(s: PmpxStr) -> OsString {
286 if s.len == 0 {
287 return OsString::new();
288 }
289 let bytes = unsafe { std::slice::from_raw_parts(s.ptr, s.len) };
290 bytes_to_os(bytes)
291}
292
293/// Read the bytes the host passed in as a `&str`, checking UTF-8; a failure returns
294/// [`PMPX_ERR_INVALID_ARGS`], and never `from_utf8_unchecked` -- that would assume the host is
295/// always correct, and the whole job of this ABI is not to make that assumption.
296/// # Safety
297/// Same as [`read_os`].
298pub unsafe fn read_str<'a>(s: PmpxStr) -> Result<&'a str, u32> {
299 if s.len == 0 {
300 return Ok("");
301 }
302 let bytes = unsafe { std::slice::from_raw_parts(s.ptr, s.len) };
303 std::str::from_utf8(bytes).map_err(|_| PMPX_ERR_INVALID_ARGS)
304}
305
306/// Convert raw bytes into an `OsString`.
307/// Public because the host side does the same thing (turning `project_root` and `args` into bytes
308/// to send across the boundary), and a separate platform `cfg` on each side would be duplication
309/// that inevitably drifts. Lossless on Unix; on other platforms `OsString` is WTF-8 underneath,
310/// so non-UTF-8 degrades to U+FFFD.
311#[cfg(unix)]
312pub fn bytes_to_os(bytes: &[u8]) -> OsString {
313 use std::os::unix::ffi::OsStringExt;
314 OsString::from_vec(bytes.to_vec())
315}
316
317/// See [`bytes_to_os`] for the platform notes.
318#[cfg(not(unix))]
319pub fn bytes_to_os(bytes: &[u8]) -> OsString {
320 String::from_utf8_lossy(bytes).into_owned().into()
321}
322
323/// Convert an `OsStr` into raw bytes. Strictly paired with [`bytes_to_os`]: lossless on Unix, on
324/// the other platforms it goes through `to_string_lossy` and non-UTF-8 degrades to U+FFFD.
325#[cfg(unix)]
326pub fn os_to_bytes(s: &OsStr) -> Vec<u8> {
327 use std::os::unix::ffi::OsStrExt;
328 s.as_bytes().to_vec()
329}
330
331/// See [`os_to_bytes`] for the platform notes.
332#[cfg(not(unix))]
333pub fn os_to_bytes(s: &OsStr) -> Vec<u8> {
334 s.to_string_lossy().into_owned().into_bytes()
335}
336
337// ---- Output direction ----
338
339/// Write a [`CommandSpec`] in its cross-boundary form, with the memory allocated by this side.
340/// # Safety
341/// `out` must point at a writable [`PmpxCommand`].
342pub unsafe fn write_command(out: *mut PmpxCommand, spec: CommandSpec) {
343 let program = leak_bytes(&os_to_bytes(&spec.program));
344
345 let args: Vec<PmpxStr> = spec
346 .args
347 .iter()
348 .map(|a| leak_bytes(&os_to_bytes(a)))
349 .collect();
350 let args_boxed: Box<[PmpxStr]> = args.into_boxed_slice();
351 let args_len = args_boxed.len();
352 let args_ptr = args_boxed.as_ptr();
353 std::mem::forget(args_boxed);
354
355 let cwd = match &spec.cwd {
356 Some(p) => leak_bytes(&os_to_bytes(p.as_os_str())),
357 None => PmpxStr::EMPTY,
358 };
359
360 unsafe {
361 *out = PmpxCommand {
362 program,
363 args: args_ptr,
364 args_len,
365 cwd,
366 };
367 }
368}
369
370// ---- Dispatch ----
371
372/// All the wiring of one `command` call: read the inputs, call
373/// [`crate::PackageManager::command`], write the output.
374/// This logic lives here rather than in the `export!` macro so that it can be tested directly.
375/// # Safety
376/// See the Safety section of [`PmpxPluginV1::command`]. In addition, `plugin` must be a valid
377/// instance in this process.
378#[allow(clippy::too_many_arguments)]
379pub unsafe fn dispatch_command(
380 plugin: &dyn PackageManager,
381 project_root: PmpxStr,
382 matched: *const PmpxStr,
383 matched_len: usize,
384 verb: u32,
385 args: *const PmpxStr,
386 args_len: usize,
387 out: *mut PmpxCommand,
388) -> u32 {
389 if out.is_null() {
390 return PMPX_ERR_INVALID_ARGS;
391 }
392
393 let Some(verb) = Verb::from_abi(verb) else {
394 return PMPX_ERR_INVALID_ARGS;
395 };
396
397 let project_root = PathBuf::from(unsafe { read_os(project_root) });
398
399 // `matched` is text (file names declared in the manifest), so UTF-8 is checked here.
400 let mut matched_names = Vec::with_capacity(matched_len);
401 for i in 0..matched_len {
402 let raw = unsafe { *matched.add(i) };
403 match unsafe { read_str(raw) } {
404 Ok(s) => matched_names.push(s.to_string()),
405 Err(code) => return code,
406 }
407 }
408
409 // `args` are arguments and may be arbitrary bytes -- converted to OsString as-is, losslessly.
410 let mut arg_list = Vec::with_capacity(args_len);
411 for i in 0..args_len {
412 let raw = unsafe { *args.add(i) };
413 arg_list.push(unsafe { read_os(raw) });
414 }
415
416 let ctx = Context {
417 project_root,
418 matched: matched_names,
419 };
420
421 match plugin.command(&ctx, verb, &arg_list) {
422 Ok(spec) => {
423 unsafe { write_command(out, spec) };
424 PMPX_OK
425 }
426 Err(e) => e.code(),
427 }
428}
429
430/// Wrap one cross-boundary call in `catch_unwind`.
431/// Since Rust 1.81, letting a panic cross an `extern "C"` boundary aborts the process outright,
432/// and the host's `catch_unwind` cannot help at all, so the plugin has to catch it itself. The
433/// host side wraps one more layer, for the cases where "the plugin forgot to wrap" or "the plugin
434/// was built with `panic=abort`".
435pub fn guard(f: impl FnOnce() -> u32) -> u32 {
436 // `AssertUnwindSafe`: once the caller has PMPX_ERR_INTERNAL it aborts the operation and never
437 // touches the caught state again.
438 std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).unwrap_or(PMPX_ERR_INTERNAL)
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444
445 #[test]
446 fn verb_numbers_match_the_public_enum() {
447 // Once the numbering slips, the host and the plugin disagree about "install" and nothing
448 // fails to compile.
449 assert_eq!(Verb::Install.to_abi(), VERB_INSTALL);
450 assert_eq!(Verb::Remove.to_abi(), VERB_REMOVE);
451 assert_eq!(Verb::Run.to_abi(), VERB_RUN);
452 assert_eq!(Verb::Build.to_abi(), VERB_BUILD);
453 assert_eq!(Verb::Test.to_abi(), VERB_TEST);
454 assert_eq!(Verb::Update.to_abi(), VERB_UPDATE);
455 assert_eq!(Verb::Exec.to_abi(), VERB_EXEC);
456 }
457
458 #[test]
459 fn verb_round_trips() {
460 for v in Verb::ALL {
461 assert_eq!(Verb::from_abi(v.to_abi()), Some(*v));
462 }
463 assert_eq!(Verb::from_abi(99), None);
464 }
465
466 #[test]
467 fn empty_str_reads_as_empty() {
468 assert_eq!(unsafe { read_os(PmpxStr::EMPTY) }, OsString::new());
469 assert_eq!(unsafe { read_str(PmpxStr::EMPTY) }.unwrap(), "");
470 }
471
472 #[test]
473 fn leak_and_free_round_trip() {
474 let s = leak_str("hello");
475 assert_eq!(s.len, 5);
476 assert_eq!(
477 unsafe { std::slice::from_raw_parts(s.ptr, s.len) },
478 b"hello"
479 );
480 unsafe { free_str(s) };
481 }
482
483 #[test]
484 fn free_str_tolerates_null() {
485 // EMPTY is passed to free_str unconditionally by free_command
486 unsafe { free_str(PmpxStr::EMPTY) };
487 }
488
489 #[test]
490 fn leak_and_free_an_empty_string() {
491 // The Box<[u8]> of an empty string is a dangling pointer (non-null, len 0) and must still
492 // free cleanly
493 let s = leak_str("");
494 assert_eq!(s.len, 0);
495 assert!(!s.ptr.is_null(), "an empty Box dangles but is not null");
496 unsafe { free_str(s) };
497 }
498
499 #[test]
500 fn read_str_rejects_invalid_utf8() {
501 let bytes = [0xff, 0xfe];
502 let s = PmpxStr {
503 ptr: bytes.as_ptr(),
504 len: bytes.len(),
505 };
506 assert_eq!(unsafe { read_str(s) }, Err(PMPX_ERR_INVALID_ARGS));
507 }
508
509 #[test]
510 fn read_os_round_trips_valid_utf8() {
511 let bytes = "/tmp/projéct/ünïcode".as_bytes();
512 let s = PmpxStr {
513 ptr: bytes.as_ptr(),
514 len: bytes.len(),
515 };
516 let got = unsafe { read_os(s) };
517 assert_eq!(os_to_bytes(&got), bytes);
518 }
519
520 /// On Unix a path or an argument may be arbitrary bytes (0xFF is not valid UTF-8, but it is a
521 /// legitimate path byte) and `OsString` must keep them losslessly.
522 #[cfg(unix)]
523 #[test]
524 fn read_os_keeps_arbitrary_bytes_on_unix() {
525 let bytes = [0x2f, 0x62, 0x61, 0x64, 0xff];
526 let s = PmpxStr {
527 ptr: bytes.as_ptr(),
528 len: bytes.len(),
529 };
530 let got = unsafe { read_os(s) };
531 assert_eq!(os_to_bytes(&got), bytes, "must be lossless on Unix");
532 }
533
534 /// Off Unix, `OsString` is WTF-8 underneath, so non-UTF-8 degrades to U+FFFD -- a platform
535 /// boundary that the test pins down as known behaviour instead of pretending otherwise.
536 #[cfg(not(unix))]
537 #[test]
538 fn read_os_replaces_invalid_utf8_off_unix() {
539 let bytes = [0x2f, 0x62, 0xff];
540 let s = PmpxStr {
541 ptr: bytes.as_ptr(),
542 len: bytes.len(),
543 };
544 let got = unsafe { read_os(s) };
545 let expected = String::from_utf8_lossy(&bytes).into_owned().into_bytes();
546 assert_eq!(os_to_bytes(&got), expected);
547 assert_ne!(os_to_bytes(&got), bytes, "off Unix it really is lossy");
548 }
549
550 #[test]
551 fn writes_and_frees_a_command() {
552 let spec = CommandSpec::new("cargo")
553 .arg("add")
554 .arg("serde")
555 .cwd("/tmp/project");
556
557 let mut out = std::mem::MaybeUninit::<PmpxCommand>::uninit();
558 unsafe { write_command(out.as_mut_ptr(), spec) };
559 let mut cmd = unsafe { out.assume_init() };
560
561 assert_eq!(cmd.args_len, 2);
562 let program = unsafe { std::slice::from_raw_parts(cmd.program.ptr, cmd.program.len) };
563 assert_eq!(program, b"cargo");
564
565 let arg0 = unsafe { *cmd.args.add(0) };
566 let a0 = unsafe { std::slice::from_raw_parts(arg0.ptr, arg0.len) };
567 assert_eq!(a0, b"add");
568
569 let cwd = unsafe { std::slice::from_raw_parts(cmd.cwd.ptr, cmd.cwd.len) };
570 assert_eq!(cwd, b"/tmp/project");
571
572 unsafe { free_command(&mut cmd as *mut _) };
573 }
574
575 #[test]
576 fn writes_a_command_with_no_args_and_no_cwd() {
577 let spec = CommandSpec::new("cargo");
578
579 let mut out = std::mem::MaybeUninit::<PmpxCommand>::uninit();
580 unsafe { write_command(out.as_mut_ptr(), spec) };
581 let mut cmd = unsafe { out.assume_init() };
582
583 assert_eq!(cmd.args_len, 0);
584 assert!(
585 cmd.cwd.is_empty(),
586 "cwd without an override should be EMPTY"
587 );
588
589 unsafe { free_command(&mut cmd as *mut _) };
590 }
591
592 #[test]
593 fn free_command_tolerates_null() {
594 unsafe { free_command(std::ptr::null_mut()) };
595 }
596
597 #[test]
598 fn guard_turns_a_panic_into_internal_error() {
599 assert_eq!(guard(|| PMPX_OK), PMPX_OK);
600 assert_eq!(guard(|| panic!("the plugin blew up")), PMPX_ERR_INTERNAL);
601 }
602
603 #[test]
604 fn build_info_is_populated() {
605 let rustc = build_rustc();
606 let target = build_target();
607 assert!(rustc.len > 0);
608 assert!(target.len > 0);
609
610 let rustc = unsafe { std::slice::from_raw_parts(rustc.ptr, rustc.len) };
611 let target = unsafe { std::slice::from_raw_parts(target.ptr, target.len) };
612 assert!(
613 std::str::from_utf8(rustc).unwrap().contains("rustc"),
614 "rustc_version should look like `rustc 1.x.y (...)`"
615 );
616 assert!(std::str::from_utf8(target).unwrap().contains('-'));
617 }
618}