pmpx-plugin 0.2.0

Defines the pmpx plugin contract and the C ABI that carries it across dlopen.
Documentation
//! End to end: compile the shell generated by `export!` into a real cdylib, load it with the very
//! library the host uses, and call it across the `dlopen` boundary.
//!
//! This is the only test that proves the following four places agree with each other -- get any
//! one of them wrong and the host cannot load the plugin, while in unit tests each is tested on
//! its own and none of them can notice:
//!
//! ```text
//! entry symbol derived by KitConfig::new("pmpx")        pmpx_plugin_entry_v1
//! exported symbol generated by export!                  pmpx_plugin_entry_v1
//! library file name derived by KitConfig::new("pmpx")   libpmpx_plugin_toy.so / pmpx_plugin_toy.dll
//! the fixture's [lib] name                              pmpx_plugin_toy
//! ```
//!
//! Paths covered: `cargo build --release` -> lay out a plugin library directory ->
//! `CratePluginKit::load()` really dlopens and fetches the symbol -> check `abi_version` -> call
//! `command()` across the boundary -> `free_command()`.

use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;

use crate_plugin_kit::{CratePluginKit, KitConfig};
use pmpx_plugin::abi::{self, PmpxCommand, PmpxPluginV1, PmpxStr};
use pmpx_plugin::Verb;

/// The crate name derived by `KitConfig::new("pmpx")` (`crate_prefix` + the short name).
const CRATE_NAME: &str = "pmpx-plugin-toy";

/// The manifest file name derived by `KitConfig::new("pmpx")`.
const MANIFEST_NAME: &str = "pmpx-plugin.toml";

const MANIFEST: &str = r#"
[plugin]
name    = "toy"
version = "0.1.0"
abi     = 1
family  = "node"

# The host's own section -- this crate does not know about it and must not touch it
[detect]
strong = [".yarnrc.yml"]
weak   = ["package.json"]
"#;

/// Compile the fixture and return (a temp dir kept alive, the cdylib path).
///
/// When `PMPX_TEST_PREBUILT_LIB` is set, use it directly and skip compiling -- that is the opening
/// left for CI's cross-toolchain job: build the fixture with an older toolchain first, then run the
/// tests with the current one, so the host and the plugin come from two different rustcs.
///
/// When it is not set, compile in place (do not copy it out, because the fixture's `Cargo.toml`
/// has a path dependency pointing at `../../..` that would break if copied elsewhere), with the
/// target directory pointed at a temp dir so no `target/` is left in the repository.
fn build_fixture() -> (tempfile::TempDir, PathBuf) {
    if let Ok(prebuilt) = std::env::var("PMPX_TEST_PREBUILT_LIB") {
        let path = PathBuf::from(&prebuilt);
        assert!(
            path.is_file(),
            "the file PMPX_TEST_PREBUILT_LIB points at does not exist: {prebuilt}"
        );
        println!(
            "using the prebuilt plugin artifact (cross-toolchain mode): {}",
            path.display()
        );
        return (
            tempfile::tempdir().expect("should be able to create a temp dir"),
            path,
        );
    }

    let tmp = tempfile::tempdir().expect("should be able to create a temp dir");

    let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("fixtures")
        .join("toy-plugin");

    let target_dir = tmp.path().join("target");

    // Called `cargo` directly: we are running inside cargo test right now, so it is definitely on
    // PATH.
    let status = Command::new("cargo")
        .args(["build", "--release", "--manifest-path"])
        .arg(fixture.join("Cargo.toml"))
        .arg("--target-dir")
        .arg(&target_dir)
        .status()
        .expect("should be able to start cargo");
    assert!(status.success(), "the fixture should compile");

    let lib = crate_plugin_kit::find_library(&target_dir.join("release"), "pmpx_plugin_toy")
        .expect("the fixture's cdylib should be among the artifacts");

    (tmp, lib)
}

/// Lay out a plugin library directory and return (a temp dir kept alive, the kit).
fn store_with_fixture(lib: &Path) -> (tempfile::TempDir, CratePluginKit<PmpxPluginV1>) {
    let tmp = tempfile::tempdir().expect("should be able to create a temp dir");
    let root = tmp.path().join("store");

    let plugin_dir = root.join("plugins").join(CRATE_NAME);
    std::fs::create_dir_all(&plugin_dir).expect("should be able to create the plugin dir");
    std::fs::write(plugin_dir.join(MANIFEST_NAME), MANIFEST)
        .expect("should be able to write the manifest");
    std::fs::copy(lib, plugin_dir.join(lib.file_name().unwrap()))
        .expect("should be able to copy the cdylib");

    let cfg = KitConfig::new("pmpx")
        .with_data_dir(&root)
        .with_lock_timeout(Duration::from_millis(500));

    // The defaults are enough: the entry symbol, the library name prefix, and the manifest name
    // are all derived from `id = "pmpx"`, and the fixture is written to exactly that convention.
    let kit = CratePluginKit::<PmpxPluginV1>::new(cfg).expect("should be able to build the kit");
    (tmp, kit)
}

/// Call `command` once per the ABI and copy the result into Rust values before freeing the
/// plugin's memory -- a miniature of the corresponding code on the pmpx side.
fn call(
    entry: &PmpxPluginV1,
    project_root: &Path,
    matched: &[&str],
    verb: Verb,
    args: &[&str],
) -> Result<(OsString, Vec<OsString>, Option<PathBuf>), u32> {
    let root_bytes = project_root.to_string_lossy().into_owned().into_bytes();
    let root_s = PmpxStr {
        ptr: root_bytes.as_ptr(),
        len: root_bytes.len(),
    };

    let matched_raw: Vec<PmpxStr> = matched
        .iter()
        .map(|s| PmpxStr {
            ptr: s.as_ptr(),
            len: s.len(),
        })
        .collect();
    let args_raw: Vec<PmpxStr> = args
        .iter()
        .map(|s| PmpxStr {
            ptr: s.as_ptr(),
            len: s.len(),
        })
        .collect();

    let mut out = std::mem::MaybeUninit::<PmpxCommand>::uninit();

    // SAFETY: the inputs are allocated by this function and stay alive for the duration of the
    // call; out points at local writable memory.
    let code = unsafe {
        (entry.command)(
            root_s,
            matched_raw.as_ptr(),
            matched_raw.len(),
            verb.to_abi(),
            args_raw.as_ptr(),
            args_raw.len(),
            out.as_mut_ptr(),
        )
    };

    if code != abi::PMPX_OK {
        return Err(code);
    }

    let mut cmd = unsafe { out.assume_init() };

    // SAFETY: after one successful call these bytes are all valid.
    let result = unsafe {
        let read = |s: PmpxStr| -> String {
            if s.is_empty() {
                return String::new();
            }
            let bytes = std::slice::from_raw_parts(s.ptr, s.len);
            String::from_utf8_lossy(bytes).into_owned()
        };

        let program: OsString = read(cmd.program).into();
        let cwd = if cmd.cwd.is_empty() {
            None
        } else {
            Some(PathBuf::from(read(cmd.cwd)))
        };
        let args: Vec<OsString> = (0..cmd.args_len)
            .map(|i| OsString::from(read(*cmd.args.add(i))))
            .collect();

        (program, args, cwd)
    };

    // SAFETY: from the successful call above, freed only once.
    unsafe { (entry.free_command)(&mut cmd as *mut _) };

    Ok(result)
}

#[test]
fn loads_a_real_cdylib_and_drives_the_vtable() {
    let (_build_tmp, lib) = build_fixture();
    let (_store_tmp, kit) = store_with_fixture(&lib);

    // list: reads the manifest only, no dlopen
    let all = kit.list().expect("list should succeed");
    assert_eq!(all.len(), 1, "{all:?}");
    assert_eq!(all[0].name, "toy");
    assert_eq!(all[0].crate_name, CRATE_NAME);

    // load: really dlopens, really fetches pmpx_plugin_entry_v1
    let loaded = kit.load("toy").expect("should be able to load");
    let entry = loaded.entry();
    assert!(!entry.is_null());

    // SAFETY: `PmpxPluginV1` is the type of the table the plugin exports; both sides are defined
    // by the same crate.
    let entry = unsafe { &*entry };

    // ABI version: the host's first action after loading
    assert_eq!(
        entry.abi_version,
        abi::ABI_VERSION,
        "the ABI version the plugin reports must match the host's"
    );

    // name / family: read strings across the boundary, then hand them back per the contract
    // SAFETY: the returned memory belongs to the plugin; free it with free_str after reading.
    unsafe {
        let name = (entry.name)();
        let bytes = std::slice::from_raw_parts(name.ptr, name.len);
        assert_eq!(bytes, b"toy");
        (entry.free_str)(name);

        let family = (entry.family)();
        let bytes = std::slice::from_raw_parts(family.ptr, family.len);
        assert_eq!(bytes, b"node");
        (entry.free_str)(family);
    }

    // command: really cross the boundary once
    let root = PathBuf::from("/tmp/project");
    let (program, args, cwd) = call(entry, &root, &[], Verb::Install, &["serde"]).expect("install");

    assert_eq!(program, OsString::from("toy-bin"));
    assert_eq!(args, vec![OsString::from("add"), OsString::from("serde")]);
    assert_eq!(cwd, None);

    // Context::matched really made it across
    let (_, args, _) = call(entry, &root, &[".yarnrc.yml"], Verb::Install, &[]).expect("berry");
    assert!(
        args.iter().any(|a| a == "--berry"),
        "the plugin should see a matched .yarnrc.yml: {args:?}"
    );

    // project_root really made it across too
    let (_, args, _) = call(entry, &root, &[], Verb::Run, &[]).expect("run");
    assert_eq!(args, vec![root.clone().into_os_string()]);
}

#[test]
fn unsupported_verb_keeps_its_code_across_the_boundary() {
    let (_build_tmp, lib) = build_fixture();
    let (_store_tmp, kit) = store_with_fixture(&lib);

    let loaded = kit.load("toy").expect("should be able to load");
    // SAFETY: same as the test above.
    let entry = unsafe { &*loaded.entry() };

    let code = call(entry, Path::new("/tmp/p"), &[], Verb::Exec, &[]).unwrap_err();
    assert_eq!(code, abi::PMPX_ERR_UNSUPPORTED_VERB);
}

/// A panic must never cross `extern "C"`: since Rust 1.81 letting a panic through aborts the
/// process, and then this test process would vanish entirely instead of reporting a failure. So
/// "the test finishes and gets INTERNAL" is the conclusion.
///
/// Note: the default panic hook prints the message to stderr, so the line
/// "this panic must be caught by guard" in the test output is expected.
#[test]
fn a_panicking_plugin_does_not_take_the_host_down() {
    let (_build_tmp, lib) = build_fixture();
    let (_store_tmp, kit) = store_with_fixture(&lib);

    let loaded = kit.load("toy").expect("should be able to load");
    // SAFETY: as above.
    let entry = unsafe { &*loaded.entry() };

    let code = call(entry, Path::new("/tmp/p"), &[], Verb::Test, &[]).unwrap_err();
    assert_eq!(code, abi::PMPX_ERR_INTERNAL);
}