Skip to main content

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}