diff --git a/Cargo.lock b/Cargo.lock index 2ff5622c1..a02d60d7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2609,6 +2609,7 @@ name = "harness" version = "0.3.0" dependencies = [ "async-trait", + "harness-plugin-loader", "harness-runner", "runvil", "runvil-plugin", @@ -2634,6 +2635,15 @@ dependencies = [ "url", ] +[[package]] +name = "harness-plugin-loader" +version = "0.3.0" +dependencies = [ + "libloading 0.8.9", + "runvil-plugin", + "thiserror 2.0.19", +] + [[package]] name = "harness-runner" version = "0.3.0" @@ -2650,6 +2660,14 @@ dependencies = [ "tracing", ] +[[package]] +name = "harness-test-plugin" +version = "0.3.0" +dependencies = [ + "harness-plugin-loader", + "runvil-plugin", +] + [[package]] name = "hashbrown" version = "0.12.3" diff --git a/Cargo.toml b/Cargo.toml index f58692368..6791d91e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["crates/*", "crates/runvil-internal/types", "crates/runvil-internal/engine", "crates/runvil-internal/lua", "crates/runvil-internal/parser", "crates/runvil-internal/vfs", "crates/runvil-internal/model-client", "crates/gateway/app", "crates/gateway/cloud-providers", "crates/gateway/config", "crates/gateway/config-ui", "crates/gateway/local", "crates/gateway/logging", "crates/gateway/progress", "crates/gateway/protocol", "crates/gateway/routing", "crates/gateway/web-search", "crates/gateway/stt/api", "crates/gateway/stt/engine", "crates/gateway/stt/backend-whisper", "crates/gateway/stt/whisper-ffi", "crates/workshop/desktop", "crates/workshop/server", "crates/workshop/server-api", "crates/workshop/gateway", "crates/workshop/menu", "crates/workshop/protocol", "crates/workshop/registry", "crates/workshop/status", "crates/workshop/support", "crates/workshop/user-state", "crates/workshop/workspace", "crates/workshop/run-log", "crates/workshop/agents", "crates/harness-internal/runner"] +members = ["crates/*", "crates/runvil-internal/types", "crates/runvil-internal/engine", "crates/runvil-internal/lua", "crates/runvil-internal/parser", "crates/runvil-internal/vfs", "crates/runvil-internal/model-client", "crates/gateway/app", "crates/gateway/cloud-providers", "crates/gateway/config", "crates/gateway/config-ui", "crates/gateway/local", "crates/gateway/logging", "crates/gateway/progress", "crates/gateway/protocol", "crates/gateway/routing", "crates/gateway/web-search", "crates/gateway/stt/api", "crates/gateway/stt/engine", "crates/gateway/stt/backend-whisper", "crates/gateway/stt/whisper-ffi", "crates/workshop/desktop", "crates/workshop/server", "crates/workshop/server-api", "crates/workshop/gateway", "crates/workshop/menu", "crates/workshop/protocol", "crates/workshop/registry", "crates/workshop/status", "crates/workshop/support", "crates/workshop/user-state", "crates/workshop/workspace", "crates/workshop/run-log", "crates/workshop/agents", "crates/harness-internal/runner", "crates/harness-internal/plugin-loader", "crates/harness-internal/test-plugin"] # crates/shared-ui is not a Rust crate: it is the TypeScript+CSS package # the Gateway config UI consumes (the Workshop uses its fork in # crates/workshop/look), so the crates/* glob skips it. @@ -47,6 +47,7 @@ gateway-api-discovery = { path = "crates/gateway-api-discovery" } harness = { path = "crates/harness" } harness-gateway-client = { path = "crates/harness-gateway-client" } harness-runner = { path = "crates/harness-internal/runner" } +harness-plugin-loader = { path = "crates/harness-internal/plugin-loader" } plugin-user-input = { path = "crates/plugin-user-input" } plugin-web = { path = "crates/plugin-web" } plugin-mcp = { path = "crates/plugin-mcp" } diff --git a/crates/build-xtask/src/harness_bans-tests.rs b/crates/build-xtask/src/harness_bans-tests.rs index 5cb60433e..ce69d9a2e 100644 --- a/crates/build-xtask/src/harness_bans-tests.rs +++ b/crates/build-xtask/src/harness_bans-tests.rs @@ -145,15 +145,20 @@ fn a_facade_directory_without_a_manifest_is_reported() { } #[test] -fn the_harness_crates_are_the_one_container_crate_and_the_facade() { +fn the_harness_crates_are_the_three_container_crates_and_the_facade() { let root = crate::product::test_support::workspace_root(); let internal = root.join("crates").join("harness-internal"); let covered = harness_crates(&internal, &root.join("crates").join("harness")); - let expected = [internal.join("runner"), root.join("crates").join("harness")]; + let expected = [ + internal.join("plugin-loader"), + internal.join("runner"), + internal.join("test-plugin"), + root.join("crates").join("harness"), + ]; assert_eq!( covered.len(), expected.len(), - "the harness container holds one crate beside the facade; covered: {covered:?}" + "the harness container holds three crates beside the facade; covered: {covered:?}" ); for dir in &expected { assert!( diff --git a/crates/build-xtask/src/harness_bans.rs b/crates/build-xtask/src/harness_bans.rs index 85dfce34c..ef39f129c 100644 --- a/crates/build-xtask/src/harness_bans.rs +++ b/crates/build-xtask/src/harness_bans.rs @@ -9,7 +9,7 @@ //! examples drive runs on tokio, as a real Host does. //! //! The check reads declared dependencies, not the resolved graph. It binds -//! the facade and both crates in the container, the set its workspace test +//! the facade and every crate in the container, the set its workspace test //! pins. use std::fs; diff --git a/crates/build-xtask/src/unsafe_allowlist-tests.rs b/crates/build-xtask/src/unsafe_allowlist-tests.rs index e0000180b..92bfff770 100644 --- a/crates/build-xtask/src/unsafe_allowlist-tests.rs +++ b/crates/build-xtask/src/unsafe_allowlist-tests.rs @@ -1,5 +1,5 @@ //! The unsafe allowlist over this workspace, plus fixtures that each way of -//! relaxing `unsafe_code` outside the four owned crates fails at its line, +//! relaxing `unsafe_code` outside the six owned crates fails at its line, //! text that only spells the attribute passes, and an empty scan fails. use std::path::Path; @@ -39,13 +39,15 @@ fn the_workspace_relaxes_unsafe_code_only_in_its_owned_crates() { } #[test] -fn the_four_owned_crates_may_relax_unsafe_code_and_text_that_spells_it_passes() { +fn the_six_owned_crates_may_relax_unsafe_code_and_text_that_spells_it_passes() { let root = tempfile::TempDir::new().expect("tempdir"); for dir in [ "gateway/app", "gateway/stt/whisper-ffi", "gateway-api-discovery", "workshop/desktop", + "harness-internal/plugin-loader", + "harness-internal/test-plugin", ] { write_crate(root.path(), dir, &[("lib.rs", RELAXES)]); } diff --git a/crates/build-xtask/src/unsafe_allowlist.rs b/crates/build-xtask/src/unsafe_allowlist.rs index c9045b40b..4df424faa 100644 --- a/crates/build-xtask/src/unsafe_allowlist.rs +++ b/crates/build-xtask/src/unsafe_allowlist.rs @@ -1,5 +1,5 @@ //! The unsafe allowlist: the workspace lint table denies `unsafe_code`, -//! and only the four crates that own an unsafe boundary may relax it. Any +//! and only the six crates that own an unsafe boundary may relax it. Any //! other `.rs` file that expects or allows `unsafe_code`, directly or //! through `cfg_attr` at any depth, is a violation. Runs as part of //! `cargo test -p build-xtask` and `cargo xtask tidy`. @@ -18,11 +18,13 @@ use syn::{Meta, Token}; /// The crate directories, relative to the workspace root, that own an /// unsafe boundary and may relax `unsafe_code` in the module that holds it. -const OWNED: [&str; 4] = [ +const OWNED: [&str; 6] = [ "crates/gateway/app", "crates/gateway/stt/whisper-ffi", "crates/gateway-api-discovery", "crates/workshop/desktop", + "crates/harness-internal/plugin-loader", + "crates/harness-internal/test-plugin", ]; /// Runs the allowlist over every `.rs` file in every workspace crate diff --git a/crates/harness-internal/plugin-loader/Cargo.toml b/crates/harness-internal/plugin-loader/Cargo.toml new file mode 100644 index 000000000..ed7161826 --- /dev/null +++ b/crates/harness-internal/plugin-loader/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "harness-plugin-loader" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[dependencies] +# Opens a Plugin's dynamic library and resolves its `runvil_plugin` symbol. +libloading.workspace = true +# The C contract's Rust mirror (`runvil_plugin::abi`), the Pack registry the +# inserts take, and `PackId`, which validates a Plugin's name. +runvil-plugin.workspace = true +# `LoadError`, why one Plugin did not load. +thiserror.workspace = true + +[lints] +workspace = true diff --git a/crates/harness-internal/plugin-loader/build.rs b/crates/harness-internal/plugin-loader/build.rs new file mode 100644 index 000000000..1ae05563b --- /dev/null +++ b/crates/harness-internal/plugin-loader/build.rs @@ -0,0 +1,13 @@ +//! Includes the file-size check's source instead of depending on +//! `build-ceiling`, because Harness crates may name no `build-*` crate. + +#[expect( + unreachable_pub, + reason = "the check's public items are private to this build script" +)] +#[path = "../../build-ceiling/src/lib.rs"] +mod build_ceiling; + +fn main() -> Result<(), build_ceiling::Violations> { + build_ceiling::check() +} diff --git a/crates/harness-internal/plugin-loader/src/fakes-tests.rs b/crates/harness-internal/plugin-loader/src/fakes-tests.rs new file mode 100644 index 000000000..c0c673a8d --- /dev/null +++ b/crates/harness-internal/plugin-loader/src/fakes-tests.rs @@ -0,0 +1,210 @@ +//! Fake entry points for the unit tests: plain `extern "C" fn`s written +//! against `runvil_plugin::abi`, and the helpers that call the `RvHost` +//! functions the way a Plugin does. Each test defines its own fake with +//! `fake!`, so its statics belong to it alone. + +use std::ffi::c_void; +use std::ptr; +use std::sync::Arc; + +use runvil_plugin::PackRegistry; +pub(crate) use runvil_plugin::abi::{ + PluginEntry, RV_ERR_MISUSE, RV_ERR_REFUSED, RV_ERR_UNSUPPORTED, RV_OK, RV_PLUGIN_LOAD, + RV_PLUGIN_UNLOAD, RvCall, RvHost, RvPluginInfo, RvStatus, RvStr, RvVerb, +}; + +use crate::host::HOST; +use crate::plugin::{Plugin, PluginFailure, insert_plugin}; + +/// A status no verb returns, for a fake that fails with its own code. +pub(crate) const ODD_STATUS: RvStatus = 77; + +/// Defines `mod $name` holding a fake entry point and its statics. Its +/// `RV_PLUGIN_LOAD` runs `$load`, which sees the call as `$call` and +/// returns the load's status. Its `RV_PLUGIN_UNLOAD` counts itself in +/// `UNLOADS`, stores the instance pointer it was sent in `UNLOADED`, +/// records whether the call carried the contract's fields in +/// `UNLOAD_WELL_FORMED`, and stores the status of a `describe_plugin` it +/// then tries in `UNLOAD_STATUS`. `LAST_CALL` is the address of the call +/// it got last. The second form adds the items a test needs the load body +/// to share with it, such as a `pub(super) static`. +macro_rules! fake { + ($name:ident, |$call:ident| $load:expr) => { + $crate::fakes::fake!($name, {}, |$call| $load); + }; + ($name:ident, { $($items:item)* }, |$call:ident| $load:expr) => { + mod $name { + use std::ffi::c_void; + use std::ptr; + use std::sync::atomic::{AtomicBool, AtomicPtr, AtomicUsize, Ordering}; + + use $crate::fakes::*; + + /// How many RV_PLUGIN_UNLOAD calls this fake got. + pub(super) static UNLOADS: AtomicUsize = AtomicUsize::new(0); + /// The instance pointer its last RV_PLUGIN_UNLOAD carried. + pub(super) static UNLOADED: AtomicPtr = AtomicPtr::new(ptr::null_mut()); + /// Whether its last RV_PLUGIN_UNLOAD carried the contract's fields. + pub(super) static UNLOAD_WELL_FORMED: AtomicBool = AtomicBool::new(false); + /// The status a `describe_plugin` during RV_PLUGIN_UNLOAD got, + /// plus one, so zero means it never tried. + pub(super) static UNLOAD_STATUS: AtomicUsize = AtomicUsize::new(0); + /// The address of the last call the fake was sent, which a + /// test reads to hand an `RvHost` function a stale call. + pub(super) static LAST_CALL: AtomicPtr = AtomicPtr::new(ptr::null_mut()); + + $($items)* + + pub(super) extern "C" fn entry($call: *mut RvCall) -> RvStatus { + LAST_CALL.store($call.cast(), Ordering::SeqCst); + match verb($call) { + RV_PLUGIN_LOAD => $load, + RV_PLUGIN_UNLOAD => { + UNLOADS.fetch_add(1, Ordering::SeqCst); + UNLOADED.store(plugin($call), Ordering::SeqCst); + UNLOAD_WELL_FORMED + .store(contract_holds($call, RV_PLUGIN_UNLOAD), Ordering::SeqCst); + let status = describe($call, "acme-url", "1.0.0"); + UNLOAD_STATUS.store(status as usize + 1, Ordering::SeqCst); + RV_OK + } + _ => RV_ERR_UNSUPPORTED, + } + } + } + }; +} +pub(crate) use fake; + +/// Inserts the linked Plugin `entry` into a fresh Pack registry. +pub(crate) fn insert(entry: PluginEntry) -> Result, PluginFailure> { + insert_plugin(&mut PackRegistry::new(), entry) +} + +/// Inserts `entry`, which must load. +pub(crate) fn loaded(entry: PluginEntry) -> Arc { + insert(entry).expect("the Plugin loads") +} + +/// Inserts `entry`, which must fail to load. +pub(crate) fn failure(entry: PluginEntry) -> PluginFailure { + insert(entry).expect_err("the Plugin fails to load") +} + +/// Whether `call` carries the fields the contract gives a call that sends +/// `verb` in version 0.1, and its table says the contract's version. A +/// load call carries no instance yet; an unload call carries one. +pub(crate) fn contract_holds(call: *mut RvCall, verb: RvVerb) -> bool { + // SAFETY: a fake runs only inside an entry point call, where `call` is + // the loader's live `RvCall` and its table is the loader's static. + let (call, table) = unsafe { (&*call, &*(*call).host) }; + let is_load = verb == RV_PLUGIN_LOAD; + call.size == u32::try_from(size_of::()).unwrap_or(0) + && call.verb == verb + && ptr::eq(call.host, &raw const HOST) + && call.plugin.is_null() == is_load + && call.pack_run.is_null() + && call.args.is_null() + && table.size == u32::try_from(size_of::()).unwrap_or(0) + && table.api_major == 0 + && table.api_minor == 1 + && table.reserved == 0 +} + +/// The verb a call carries. +pub(crate) fn verb(call: *mut RvCall) -> RvVerb { + // SAFETY: as in `contract_holds`. + unsafe { (*call).verb } +} + +/// The instance pointer a call carries. +pub(crate) fn plugin(call: *mut RvCall) -> *mut c_void { + // SAFETY: as in `contract_holds`. + unsafe { (*call).plugin } +} + +/// Sets the instance pointer, as RV_PLUGIN_LOAD may. +pub(crate) fn set_plugin(call: *mut RvCall, plugin: *mut c_void) { + // SAFETY: as in `contract_holds`, and nothing else reads the call + // meanwhile. + unsafe { (*call).plugin = plugin }; +} + +/// An address that is never null, which a fake hands out as its instance. +pub(crate) fn instance(tag: usize) -> *mut c_void { + ptr::without_provenance_mut(tag) +} + +/// The `RvHost` table a call carries. +pub(crate) fn host(call: *mut RvCall) -> &'static RvHost { + // SAFETY: as in `contract_holds`. + unsafe { &*(*call).host } +} + +/// A string for the C side, borrowed from `text`. +pub(crate) fn rv_str(text: &str) -> RvStr { + RvStr { + ptr: text.as_ptr().cast(), + len: text.len(), + } +} + +/// A string for the C side, borrowed from `bytes`. +pub(crate) fn rv_str_bytes(bytes: &[u8]) -> RvStr { + RvStr { + ptr: bytes.as_ptr().cast(), + len: bytes.len(), + } +} + +/// A well-formed `RvPluginInfo`. +pub(crate) fn info(name: RvStr, version: RvStr) -> RvPluginInfo { + RvPluginInfo { + size: u32::try_from(size_of::()).expect("the info's size fits a u32"), + reserved: 0, + name, + version, + } +} + +/// Calls the call's `describe_plugin` with `info`. +pub(crate) fn describe_info(call: *mut RvCall, info: &RvPluginInfo) -> RvStatus { + describe_as(call, call, info) +} + +/// Calls the `describe_plugin` of `table_from`'s table, handing it `as_call`. +pub(crate) fn describe_as( + table_from: *mut RvCall, + as_call: *const RvCall, + info: &RvPluginInfo, +) -> RvStatus { + // SAFETY: `info` is a live `RvPluginInfo` whose strings outlive the + // call, and the function never dereferences `as_call`. + unsafe { (host(table_from).describe_plugin)(as_call, info) } +} + +/// Describes the Plugin with `name` and `version`. +pub(crate) fn describe(call: *mut RvCall, name: &str, version: &str) -> RvStatus { + describe_info(call, &info(rv_str(name), rv_str(version))) +} + +/// Calls the `refuse` of `table_from`'s table, handing it `as_call`. +pub(crate) fn refuse_as( + table_from: *mut RvCall, + as_call: *const RvCall, + reason: RvStr, +) -> RvStatus { + // SAFETY: `reason` outlives the call, and the function never + // dereferences `as_call`. + unsafe { (host(table_from).refuse)(as_call, reason) } +} + +/// Calls the call's `refuse` with `reason`. +pub(crate) fn refuse_str(call: *mut RvCall, reason: RvStr) -> RvStatus { + refuse_as(call, call, reason) +} + +/// Refuses the load with `reason`. +pub(crate) fn refuse(call: *mut RvCall, reason: &str) -> RvStatus { + refuse_str(call, rv_str(reason)) +} diff --git a/crates/harness-internal/plugin-loader/src/host-tests.rs b/crates/harness-internal/plugin-loader/src/host-tests.rs new file mode 100644 index 000000000..153fa953e --- /dev/null +++ b/crates/harness-internal/plugin-loader/src/host-tests.rs @@ -0,0 +1,428 @@ +//! Tests for the `RvHost` boundary: what `describe_plugin` and `refuse` +//! accept, what they answer RV_ERR_MISUSE, which call they answer, and +//! which failure the loader reports when a Plugin misuses the table. + +use std::sync::atomic::Ordering; + +use crate::fakes::*; +use crate::host::HOST; + +/// What a name that is not one Pack id segment is reported as. +macro_rules! bad_name { + () => { + "the name is not one Pack id segment, such as `acme-url`" + }; +} + +/// The reason of a failure caused by a misused `RvHost` table. +macro_rules! misuse_reason { + ($detail:expr) => { + concat!("the Plugin misused the RvHost table: ", $detail) + }; +} + +/// Defines a test whose fake load runs `$misuse`, an `RvHost` call that +/// must answer RV_ERR_MISUSE, and then returns RV_OK. The load must fail +/// with `$detail`, and a failed load is never unloaded. +macro_rules! misuse_test { + ($test:ident, $detail:expr, |$call:ident| $misuse:expr) => { + #[test] + fn $test() { + fake!( + plug, + { + pub(super) static SEEN: AtomicUsize = AtomicUsize::new(0); + }, + |$call| { + let status: RvStatus = $misuse; + SEEN.store(status as usize, Ordering::SeqCst); + RV_OK + } + ); + let failure = failure(plug::entry); + assert_eq!(failure.reason(), misuse_reason!($detail)); + assert_eq!(plug::SEEN.load(Ordering::SeqCst), RV_ERR_MISUSE as usize); + assert_eq!(plug::UNLOADS.load(Ordering::SeqCst), 0); + } + }; +} + +misuse_test!( + a_name_over_64_bytes_is_misuse, + "the name is longer than 64 bytes", + |call| describe(call, &"a".repeat(65), "1.0.0") +); + +misuse_test!( + a_name_with_a_null_pointer_and_a_length_is_misuse, + "the name has a NULL pointer and a nonzero length", + |call| describe_info( + call, + &info( + RvStr { + ptr: ptr::null(), + len: 4 + }, + rv_str("1.0.0") + ) + ) +); + +misuse_test!(a_null_empty_name_is_misuse, bad_name!(), |call| { + describe_info( + call, + &info( + RvStr { + ptr: ptr::null(), + len: 0, + }, + rv_str("1.0.0"), + ), + ) +}); + +misuse_test!(a_name_with_two_segments_is_misuse, bad_name!(), |call| { + describe(call, "acme/url", "1.0.0") +}); + +misuse_test!( + a_name_with_an_uppercase_letter_is_misuse, + bad_name!(), + |call| describe(call, "Acme", "1.0.0") +); + +misuse_test!( + a_name_that_is_not_utf8_is_misuse, + "the name is not UTF-8", + |call| describe_info(call, &info(rv_str_bytes(&[0xff, 0xfe]), rv_str("1.0.0"))) +); + +misuse_test!(an_empty_version_is_misuse, "the version is empty", |call| { + describe(call, "acme-url", "") +}); + +misuse_test!( + a_version_over_64_bytes_is_misuse, + "the version is longer than 64 bytes", + |call| describe(call, "acme-url", &"1".repeat(65)) +); + +misuse_test!( + a_version_with_a_control_character_is_misuse, + "the version has a control character", + |call| describe(call, "acme-url", "1.0\n0") +); + +misuse_test!( + a_version_with_a_null_pointer_and_a_length_is_misuse, + "the version has a NULL pointer and a nonzero length", + |call| describe_info( + call, + &info( + rv_str("acme-url"), + RvStr { + ptr: ptr::null(), + len: 3 + } + ) + ) +); + +misuse_test!( + a_short_info_size_is_misuse, + "the info's size is shorter than RvPluginInfo", + |call| describe_info( + call, + &RvPluginInfo { + size: 0, + ..info(rv_str("acme-url"), rv_str("1.0.0")) + } + ) +); + +misuse_test!( + an_info_size_one_byte_short_is_misuse, + "the info's size is shorter than RvPluginInfo", + |call| { + let whole = info(rv_str("acme-url"), rv_str("1.0.0")); + describe_info( + call, + &RvPluginInfo { + size: whole.size - 1, + ..whole + }, + ) + } +); + +misuse_test!( + a_nonzero_reserved_field_is_misuse, + "the info's reserved field is not zero", + |call| describe_info( + call, + &RvPluginInfo { + reserved: 1, + ..info(rv_str("acme-url"), rv_str("1.0.0")) + } + ) +); + +misuse_test!( + a_null_info_is_misuse, + "describe_plugin was handed a NULL info", + |call| { + // SAFETY: the loader's function accepts a NULL info and answers + // misuse; the call is live. + unsafe { (host(call).describe_plugin)(call, ptr::null()) } + } +); + +misuse_test!( + a_misaligned_info_is_misuse, + "describe_plugin was handed a misaligned info", + |call| { + // SAFETY: the loader's function answers misuse for a misaligned + // info without reading it, so address 1 is safe to hand it; the + // call is live. + unsafe { (host(call).describe_plugin)(call, ptr::without_provenance(1)) } + } +); + +misuse_test!( + a_second_description_is_misuse, + "describe_plugin was called twice", + |call| { + describe(call, "acme-url", "1.0.0"); + describe(call, "acme-url", "1.0.0") + } +); + +misuse_test!( + a_refusal_reason_over_1024_bytes_is_misuse, + "the refusal reason is longer than 1024 bytes", + |call| refuse(call, &"r".repeat(1025)) +); + +misuse_test!( + a_refusal_reason_that_is_not_utf8_is_misuse, + "the refusal reason is not UTF-8", + |call| refuse_str(call, rv_str_bytes(&[0xc3, 0x28])) +); + +misuse_test!( + a_refusal_reason_with_a_null_pointer_and_a_length_is_misuse, + "the refusal reason has a NULL pointer and a nonzero length", + |call| refuse_str( + call, + RvStr { + ptr: ptr::null(), + len: 5 + } + ) +); + +misuse_test!( + a_call_other_than_the_open_one_is_misuse, + "an RvHost function was handed a call other than the open RV_PLUGIN_LOAD call", + |call| { + let mut other = RvCall { + size: 0, + verb: RV_PLUGIN_LOAD, + host: ptr::null(), + plugin: ptr::null_mut(), + pack_run: ptr::null_mut(), + args: ptr::null(), + }; + let other: *const RvCall = &raw mut other; + describe_as(call, other, &info(rv_str("acme-url"), rv_str("1.0.0"))) + } +); + +misuse_test!( + a_null_call_is_misuse, + "an RvHost function was handed a call other than the open RV_PLUGIN_LOAD call", + |call| refuse_as(call, ptr::null(), rv_str("no")) +); + +#[test] +fn a_name_and_a_version_of_exactly_64_bytes_are_accepted() { + fake!(plug, |call| describe( + call, + &"a".repeat(64), + &"1".repeat(64) + )); + let plugin = loaded(plug::entry); + assert_eq!(plugin.name(), "a".repeat(64)); + assert_eq!(plugin.version(), "1".repeat(64)); +} + +#[test] +fn a_refusal_reason_of_exactly_1024_bytes_is_accepted() { + fake!(plug, |call| refuse(call, &"r".repeat(1024))); + let failure = failure(plug::entry); + assert_eq!( + failure.reason(), + format!("the Plugin refused: {}", "r".repeat(1024)) + ); +} + +#[test] +fn a_refusal_with_no_reason_is_accepted() { + fake!( + plug, + { + pub(super) static SEEN: AtomicUsize = AtomicUsize::new(0); + }, + |call| { + let status = refuse_str( + call, + RvStr { + ptr: ptr::null(), + len: 0, + }, + ); + SEEN.store(status as usize, Ordering::SeqCst); + RV_ERR_REFUSED + } + ); + let failure = failure(plug::entry); + assert_eq!(failure.reason(), "the Plugin refused: "); + assert_eq!(plug::SEEN.load(Ordering::SeqCst), RV_ERR_REFUSED as usize); +} + +#[test] +fn a_second_refusal_is_misuse_and_the_first_is_reported() { + fake!( + plug, + { + pub(super) static SEEN: AtomicUsize = AtomicUsize::new(0); + }, + |call| { + refuse(call, "first"); + let second = refuse(call, "second"); + SEEN.store(second as usize, Ordering::SeqCst); + RV_ERR_REFUSED + } + ); + let failure = failure(plug::entry); + assert_eq!(failure.reason(), "the Plugin refused: first"); + assert_eq!(plug::SEEN.load(Ordering::SeqCst), RV_ERR_MISUSE as usize); +} + +#[test] +fn a_refusal_wins_over_a_misuse_made_before_it() { + fake!(plug, |call| { + describe(call, "", "1.0.0"); + refuse(call, "no thanks") + }); + assert_eq!( + failure(plug::entry).reason(), + "the Plugin refused: no thanks" + ); +} + +#[test] +fn a_misuse_followed_by_a_non_ok_status_reports_the_misuse() { + fake!(plug, |call| { + describe(call, "", "1.0.0"); + ODD_STATUS + }); + assert_eq!(failure(plug::entry).reason(), misuse_reason!(bad_name!())); +} + +#[test] +fn a_misuse_is_reported_even_when_a_good_description_follows() { + fake!( + plug, + { + pub(super) static SEEN: AtomicUsize = AtomicUsize::new(0); + }, + |call| { + let status = describe(call, "Not A Name", "1.0.0"); + SEEN.store(status as usize, Ordering::SeqCst); + describe(call, "acme-url", "1.0.0") + } + ); + let failure = failure(plug::entry); + assert_eq!(failure.reason(), misuse_reason!(bad_name!())); + assert_eq!(plug::SEEN.load(Ordering::SeqCst), RV_ERR_MISUSE as usize); +} + +#[test] +fn an_rvhost_function_called_from_another_thread_is_misuse() { + fake!( + plug, + { + pub(super) static SEEN: AtomicUsize = AtomicUsize::new(0); + }, + |call| { + let address = call as usize; + let (described, refused) = std::thread::spawn(move || { + let call = ptr::with_exposed_provenance_mut::(address); + ( + describe(call, "acme-url", "1.0.0"), + refuse(call, "from another thread"), + ) + }) + .join() + .expect("the thread finishes"); + SEEN.store( + usize::from(described == RV_ERR_MISUSE && refused == RV_ERR_MISUSE), + Ordering::SeqCst, + ); + describe(call, "acme-url", "1.0.0") + } + ); + let plugin = loaded(plug::entry); + assert_eq!( + plug::SEEN.load(Ordering::SeqCst), + 1, + "both calls from the other thread got misuse" + ); + assert_eq!(plugin.name(), "acme-url"); +} + +#[test] +fn an_rvhost_function_called_after_the_load_returned_is_misuse() { + fake!(plug, |call| describe(call, "acme-url", "1.0.0")); + let _plugin = loaded(plug::entry); + let stale = plug::LAST_CALL + .load(Ordering::SeqCst) + .cast::() + .cast_const(); + let info = info(rv_str("other"), rv_str("2.0.0")); + // SAFETY: the loader's functions never dereference the call, so a + // stale pointer is safe to hand them, and `info` is live. + let described = unsafe { (HOST.describe_plugin)(stale, &raw const info) }; + // SAFETY: as above. + let refused = unsafe { (HOST.refuse)(stale, rv_str("too late")) }; + assert_eq!(described, RV_ERR_MISUSE); + assert_eq!(refused, RV_ERR_MISUSE); +} + +#[test] +fn the_load_call_carries_the_contract_fields() { + fake!( + plug, + { + pub(super) static SEEN: AtomicUsize = AtomicUsize::new(0); + }, + |call| { + SEEN.store( + usize::from(contract_holds(call, RV_PLUGIN_LOAD)), + Ordering::SeqCst, + ); + describe(call, "acme-url", "1.0.0") + } + ); + let _plugin = loaded(plug::entry); + assert_eq!(plug::SEEN.load(Ordering::SeqCst), 1); +} + +#[test] +fn the_table_reports_version_0_1() { + assert_eq!(HOST.api_major, 0); + assert_eq!(HOST.api_minor, 1); + assert_eq!(HOST.reserved, 0); + assert_eq!(usize::try_from(HOST.size), Ok(size_of::())); +} diff --git a/crates/harness-internal/plugin-loader/src/host.rs b/crates/harness-internal/plugin-loader/src/host.rs new file mode 100644 index 000000000..f39c9810d --- /dev/null +++ b/crates/harness-internal/plugin-loader/src/host.rs @@ -0,0 +1,335 @@ +//! The `RvHost` table every call hands a Plugin, the functions in it, and +//! the two verb senders. A thread-local session ties the functions to the +//! one `RV_PLUGIN_LOAD` call they may answer. + +use std::cell::Cell; +use std::ffi::c_void; +use std::mem::size_of; +use std::{ptr, slice}; + +use runvil_plugin::PackId; +use runvil_plugin::abi::{ + PluginEntry, RV_API_MAJOR, RV_API_MINOR, RV_ERR_MISUSE, RV_ERR_REFUSED, RV_OK, RV_PLUGIN_LOAD, + RV_PLUGIN_UNLOAD, RvCall, RvHost, RvPluginInfo, RvStatus, RvStr, +}; + +use crate::load::LoadError; + +#[cfg(test)] +#[path = "host-tests.rs"] +mod tests; + +/// The `RvHost` table every call hands the Plugin. +pub(crate) static HOST: RvHost = RvHost { + size: size_field::(), + api_major: RV_API_MAJOR, + api_minor: RV_API_MINOR, + reserved: 0, + refuse, + describe_plugin, +}; + +thread_local! { + /// The RV_PLUGIN_LOAD call running on this thread, as a `Session` that + /// is idle when none is. A `Cell`, so no borrow is ever held and no + /// borrow check can fail: `answer` takes the session out while it + /// works, and `send_load` and `send_unload` swap it. + static SESSION: Cell = const { Cell::new(Session::idle()) }; +} + +/// What one RV_PLUGIN_LOAD has reported so far, or an idle session when no +/// RV_PLUGIN_LOAD call is running. +struct Session { + /// The address of the open call, compared and never dereferenced; it + /// is `None` while the session is idle. + call: Option<*const RvCall>, + described: Option, + refusal: Option, + misuse: Option<&'static str>, +} + +impl Session { + /// A session with no call open. + const fn idle() -> Session { + Session { + call: None, + described: None, + refusal: None, + misuse: None, + } + } + + /// A session open on `call`, with nothing reported yet. + fn open(call: *const RvCall) -> Session { + Session { + call: Some(call), + ..Session::idle() + } + } +} + +impl Default for Session { + fn default() -> Session { + Session::idle() + } +} + +/// The name and version a Plugin gave `describe_plugin`, copied. +pub(crate) struct Described { + pub(crate) name: String, + pub(crate) version: String, +} + +/// One string field's bound, and what each way of breaking it is called. +struct Field { + cap: usize, + too_long: &'static str, + null_with_length: &'static str, + not_utf8: &'static str, +} + +const NAME: Field = Field { + cap: 64, + too_long: "the name is longer than 64 bytes", + null_with_length: "the name has a NULL pointer and a nonzero length", + not_utf8: "the name is not UTF-8", +}; + +const VERSION: Field = Field { + cap: 64, + too_long: "the version is longer than 64 bytes", + null_with_length: "the version has a NULL pointer and a nonzero length", + not_utf8: "the version is not UTF-8", +}; + +const REASON: Field = Field { + cap: 1024, + too_long: "the refusal reason is longer than 1024 bytes", + null_with_length: "the refusal reason has a NULL pointer and a nonzero length", + not_utf8: "the refusal reason is not UTF-8", +}; + +impl Field { + /// Copies the string `s` holds. The length is checked against the cap + /// before any byte is read, and `s.ptr` is read only when `s.len` is + /// nonzero, because `slice::from_raw_parts` with a NULL pointer is + /// undefined behavior even at length 0. + /// + /// # Safety + /// When `s.len` is nonzero, `s.ptr` must be valid for reads of `s.len` + /// bytes for the duration of this call. + unsafe fn read(&self, s: RvStr) -> Result { + if s.len > self.cap { + return Err(self.too_long); + } + if s.len == 0 { + return Ok(String::new()); + } + if s.ptr.is_null() { + return Err(self.null_with_length); + } + // SAFETY: `s.ptr` is not NULL and `s.len` is at most the cap, and + // the caller guarantees `s.ptr` is valid for `s.len` bytes. + let bytes = unsafe { slice::from_raw_parts(s.ptr.cast::(), s.len) }; + std::str::from_utf8(bytes) + .map(str::to_owned) + .map_err(|_| self.not_utf8) + } +} + +/// `size_of::()` as the `u32` a contract struct's `size` field holds. +#[expect( + clippy::cast_possible_truncation, + reason = "the assertion bounds the size to a u32" +)] +const fn size_field() -> u32 { + assert!( + size_of::() <= u32::MAX as usize, + "a contract struct's size fits its u32 size field" + ); + size_of::() as u32 +} + +/// Answers an `RvHost` function. `f` runs only when this thread has an +/// RV_PLUGIN_LOAD call open and `call` is its address; otherwise the +/// answer is RV_ERR_MISUSE, and an open session on this thread records +/// the misuse. A thread whose session slot is already destroyed has no +/// session, so it answers RV_ERR_MISUSE too. Nothing here dereferences +/// `call`. +/// +/// The session is out of its slot while `f` runs, so an `RvHost` function +/// that `f` somehow reached would find an idle session and answer +/// RV_ERR_MISUSE. +fn answer(call: *const RvCall, f: impl FnOnce(&mut Session) -> RvStatus) -> RvStatus { + SESSION + .try_with(|cell| { + let mut session = cell.take(); + let status = match session.call { + Some(open) if ptr::eq(open, call) => f(&mut session), + Some(_) => { + session.misuse.get_or_insert( + "an RvHost function was handed a call other than the open RV_PLUGIN_LOAD call", + ); + RV_ERR_MISUSE + } + None => RV_ERR_MISUSE, + }; + cell.set(session); + status + }) + .unwrap_or(RV_ERR_MISUSE) +} + +/// Records the Plugin's refusal, validated; returns RV_ERR_REFUSED, or +/// RV_ERR_MISUSE for a call it doesn't answer or a second refusal. +unsafe extern "C" fn refuse(call: *const RvCall, reason: RvStr) -> RvStatus { + answer(call, |session| { + if session.refusal.is_some() { + session.misuse.get_or_insert("refuse was called twice"); + return RV_ERR_MISUSE; + } + // SAFETY: the contract makes `reason` valid for its length during + // this call. + match unsafe { REASON.read(reason) } { + Ok(reason) => { + session.refusal = Some(reason); + RV_ERR_REFUSED + } + Err(fault) => { + session.misuse.get_or_insert(fault); + RV_ERR_MISUSE + } + } + }) +} + +/// Records the Plugin's description once, validated; returns RV_OK or +/// RV_ERR_MISUSE. +unsafe extern "C" fn describe_plugin(call: *const RvCall, info: *const RvPluginInfo) -> RvStatus { + answer(call, |session| { + // SAFETY: the contract makes `info` point at a readable + // `RvPluginInfo` during this call. + match unsafe { describe(session, info) } { + Ok(()) => RV_OK, + Err(fault) => { + session.misuse.get_or_insert(fault); + RV_ERR_MISUSE + } + } + }) +} + +/// Validates `info` and copies the name and version into `session`. +/// +/// # Safety +/// When `info` is not NULL, it must be readable as an `RvPluginInfo` +/// whose `size` field says how much of it the Plugin wrote, and each +/// string in it must be valid for its length, for the duration of this +/// call. +unsafe fn describe(session: &mut Session, info: *const RvPluginInfo) -> Result<(), &'static str> { + if session.described.is_some() { + return Err("describe_plugin was called twice"); + } + if info.is_null() { + return Err("describe_plugin was handed a NULL info"); + } + if !info.is_aligned() { + return Err("describe_plugin was handed a misaligned info"); + } + // SAFETY: `info` is not NULL and is aligned, and `size` is the first + // field of a struct the Plugin wrote, so it is readable on its own. + let size = unsafe { (&raw const (*info).size).read() }; + if (size as usize) < size_of::() { + return Err("the info's size is shorter than RvPluginInfo"); + } + // SAFETY: `size` says the Plugin wrote a whole `RvPluginInfo`. + let info = unsafe { &*info }; + if info.reserved != 0 { + return Err("the info's reserved field is not zero"); + } + // SAFETY: the caller makes each string valid for its length. + let name = unsafe { NAME.read(info.name) }?; + if PackId::parse(&name).is_err() { + return Err("the name is not one Pack id segment, such as `acme-url`"); + } + // SAFETY: as above. + let version = unsafe { VERSION.read(info.version) }?; + if version.is_empty() { + return Err("the version is empty"); + } + if version.chars().any(char::is_control) { + return Err("the version has a control character"); + } + session.described = Some(Described { name, version }); + Ok(()) +} + +/// Sends RV_PLUGIN_LOAD through `entry` with a session open on this thread, then +/// closes it. On success, returns the description and the instance +/// pointer the Plugin set. +/// +/// # Safety +/// `entry` must follow the C contract in `runvil_plugin::abi`, and must +/// not unwind. +pub(crate) unsafe fn send_load(entry: PluginEntry) -> Result<(Described, *mut c_void), LoadError> { + let mut call = RvCall { + size: size_field::(), + verb: RV_PLUGIN_LOAD, + host: &raw const HOST, + plugin: ptr::null_mut(), + pack_run: ptr::null_mut(), + args: ptr::null(), + }; + let call_ptr = &raw mut call; + // A load inside a load, on one thread, keeps the outer session. + let outer = SESSION.with(|cell| cell.replace(Session::open(call_ptr.cast_const()))); + // SAFETY: the caller guarantees `entry` follows the contract, and + // `call_ptr` points at a live `RvCall` that nothing else uses. + let status = unsafe { entry(call_ptr) }; + // Everything that touches the slot while `entry` runs (`answer`, + // `send_load`, and `send_unload`) puts back what it replaced, so this + // swap returns the session opened above. + let session = SESSION.with(|cell| cell.replace(outer)); + if let Some(reason) = session.refusal { + return Err(LoadError::Refused(reason)); + } + if let Some(reason) = session.misuse { + return Err(LoadError::Misuse(reason)); + } + if status != RV_OK { + return Err(LoadError::Status(status)); + } + match session.described { + Some(described) => Ok((described, call.plugin)), + None => Err(LoadError::Undescribed), + } +} + +/// Sends RV_PLUGIN_UNLOAD through `entry` with `plugin`. No session is open, so +/// any `RvHost` function the Plugin calls answers RV_ERR_MISUSE. The +/// status is ignored, because a Plugin that is going away has no one to +/// tell. +/// +/// # Safety +/// `entry` must follow the C contract and must not unwind, and `plugin` +/// must be the pointer its RV_PLUGIN_LOAD set, not yet unloaded. +pub(crate) unsafe fn send_unload(entry: PluginEntry, plugin: *mut c_void) { + let mut call = RvCall { + size: size_field::(), + verb: RV_PLUGIN_UNLOAD, + host: &raw const HOST, + plugin, + pack_run: ptr::null_mut(), + args: ptr::null(), + }; + // An unload inside a load, on one thread, must not see the load's + // session. The last `Arc` of a Plugin may drop inside a thread-local + // destructor that runs after this one's slot is destroyed. Such a thread + // has no session to hide, and `answer` treats its destroyed slot as "no + // session", so the unload still goes out. + let outer = SESSION.try_with(Cell::take).unwrap_or_default(); + // SAFETY: the caller guarantees `entry` follows the contract and + // `plugin` is the live instance, and `call` is a live `RvCall`. + let _status = unsafe { entry(&raw mut call) }; + let _restored = SESSION.try_with(|cell| cell.set(outer)); +} diff --git a/crates/harness-internal/plugin-loader/src/lib.rs b/crates/harness-internal/plugin-loader/src/lib.rs new file mode 100644 index 000000000..2d1882b0a --- /dev/null +++ b/crates/harness-internal/plugin-loader/src/lib.rs @@ -0,0 +1,36 @@ +//! `harness-plugin-loader` - inserts Plugins into a Host's Pack registry, +//! from dynamic libraries in a Plugin directory or linked into the Host. +//! Each Plugin loads as it is inserted. +//! +//! ## Invariants +//! +//! - May depend on: `runvil-plugin` among workspace crates. +//! - Every entry point gets the same calls, whether it came from a library +//! or was linked in. The only difference is that a library is opened +//! when it is inserted and closed after its Plugin is dropped. +//! - Dropping a `Plugin` sends `RV_PLUGIN_UNLOAD` once, with the instance +//! pointer its `RV_PLUGIN_LOAD` set, then closes its library, if it has +//! one. +//! - An entry point whose `RV_PLUGIN_LOAD` fails gets no +//! `RV_PLUGIN_UNLOAD`, and its library is closed at once; a failing load +//! must leave nothing running. +//! - The loader sends only `RV_PLUGIN_LOAD` and `RV_PLUGIN_UNLOAD`, and the +//! `RvHost` functions answer only the `RV_PLUGIN_LOAD` call they were +//! handed, on its thread. +//! - Nothing the loader keeps points into a library except its entry point +//! and the Plugin's instance pointer; the name and version are copied. +#![expect( + unsafe_code, + reason = "opens dynamic libraries and calls C entry points" +)] + +#[cfg(test)] +#[path = "fakes-tests.rs"] +mod fakes; +mod host; +mod load; +mod plugin; + +pub use plugin::{ + Plugin, PluginFailure, PluginReport, PluginSource, insert_plugin, insert_plugin_dir, +}; diff --git a/crates/harness-internal/plugin-loader/src/load-tests.rs b/crates/harness-internal/plugin-loader/src/load-tests.rs new file mode 100644 index 000000000..ac9d51ff4 --- /dev/null +++ b/crates/harness-internal/plugin-loader/src/load-tests.rs @@ -0,0 +1,264 @@ +//! Tests for listing and opening Plugin libraries: which files in a Plugin +//! directory count, in what order, with what paths, and how a library +//! without `runvil_plugin` fails. + +use std::env::consts::DLL_EXTENSION; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use crate::load::{is_regular_file, list_plugin_files, plugin_files}; + +/// Tells apart the directories one process makes; nextest runs each test +/// in its own process, so the process id tells those apart. +static NEXT_DIRECTORY: AtomicU64 = AtomicU64::new(0); + +/// A directory made for one test at `/