use crate::builder::PluginRegistryBuilder;
use std::fmt;
pub const LINKING_GUIDE: &str = "plugins/linking-a-dialect.html in the razeri book";
pub const PLUGIN_REGISTER_SYMBOL: &str = "razeri_plugin_register_v1";
pub const PLUGIN_BUILD_SYMBOL: &str = "razeri_plugin_build";
pub type RegisterPluginFn = unsafe fn(&mut PluginRegistryBuilder);
pub type BuildDeclarationFn = extern "C" fn() -> *const export::ExportedBuildDeclaration;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BuildDeclaration {
pub compiler: String,
pub plugin_api: String,
pub contract: String,
pub source: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BuildPart {
Compiler,
PluginApi,
Contract,
Source,
}
impl BuildDeclaration {
#[must_use]
pub fn this_build() -> Self {
Self {
compiler: export::COMPILER.to_string_lossy().into_owned(),
plugin_api: export::PLUGIN_API.to_string_lossy().into_owned(),
contract: export::CONTRACT.to_string_lossy().into_owned(),
source: export::SOURCE,
}
}
#[must_use]
pub fn differences(&self, needs: &Self) -> Vec<BuildPart> {
let mut parts = Vec::new();
if self.compiler != needs.compiler {
parts.push(BuildPart::Compiler);
}
if self.plugin_api != needs.plugin_api {
parts.push(BuildPart::PluginApi);
}
if self.contract != needs.contract {
parts.push(BuildPart::Contract);
}
let versions_agree = self.plugin_api == needs.plugin_api && self.contract == needs.contract;
if versions_agree && self.source != needs.source {
parts.push(BuildPart::Source);
}
parts
}
}
impl fmt::Display for BuildDeclaration {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} against razeri-plugin-api {} and razeri-plugin-contract {} (source {:016x})",
self.compiler, self.plugin_api, self.contract, self.source
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PluginRefusal {
Undeclared,
Unreadable {
form: u32,
},
BuiltDifferently {
built_with: BuildDeclaration,
},
}
impl PluginRefusal {
#[must_use]
pub fn for_declaration(built_with: BuildDeclaration) -> Option<Self> {
if built_with.differences(&BuildDeclaration::this_build()).is_empty() {
None
} else {
Some(Self::BuiltDifferently { built_with })
}
}
}
impl fmt::Display for PluginRefusal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let needs = BuildDeclaration::this_build();
let only_the_source_differs = matches!(
self,
Self::BuiltDifferently { built_with }
if built_with.differences(&needs) == [BuildPart::Source]
);
if only_the_source_differs {
write!(
f,
"Rebuild it with {} against the same source of razeri-plugin-api {} and \
razeri-plugin-contract {} this razeri was built from (source {:016x})",
needs.compiler, needs.plugin_api, needs.contract, needs.source
)?;
} else {
write!(
f,
"Rebuild it with {} against razeri-plugin-api {} and razeri-plugin-contract {}",
needs.compiler, needs.plugin_api, needs.contract
)?;
}
if matches!(self, Self::Undeclared) {
f.write_str(", exporting it with razeri_plugin_api::export_plugin!")?;
}
write!(f, ", or link it into a razeri you build yourself ({LINKING_GUIDE}). ")?;
match self {
Self::Undeclared => f.write_str("It does not say how it was built")?,
Self::Unreadable { form, .. } => write!(
f,
"It says how it was built in a form this razeri does not read (form {form})"
)?,
Self::BuiltDifferently { built_with } => {
write!(f, "It was built {}", built_differently(built_with, &needs))?;
}
}
f.write_str(
", and a plugin loaded from a directory must be built exactly as the razeri that \
loads it.",
)
}
}
impl std::error::Error for PluginRefusal {}
fn built_differently(built_with: &BuildDeclaration, needs: &BuildDeclaration) -> String {
let parts = built_with.differences(needs);
let mut clauses = Vec::new();
if parts.contains(&BuildPart::Compiler) {
clauses.push(format!("with {}", built_with.compiler));
}
let mut against = Vec::new();
if parts.contains(&BuildPart::PluginApi) {
against.push(format!("razeri-plugin-api {}", built_with.plugin_api));
}
if parts.contains(&BuildPart::Contract) {
against.push(format!("razeri-plugin-contract {}", built_with.contract));
}
if !against.is_empty() {
clauses.push(format!("against {}", against.join(" and ")));
}
if parts.contains(&BuildPart::Source) {
clauses.push(format!(
"against another source of those two versions (source {:016x})",
built_with.source
));
}
clauses.join(" ")
}
#[derive(Debug)]
pub enum PluginLoadResult {
Loaded {
name: String,
path: std::path::PathBuf,
},
Refused {
path: std::path::PathBuf,
refusal: PluginRefusal,
},
Failed {
path: std::path::PathBuf,
error: String,
},
}
pub unsafe fn read_declaration(
record: *const export::ExportedBuildDeclaration,
) -> Result<BuildDeclaration, PluginRefusal> {
let unreadable = |form| PluginRefusal::Unreadable { form };
if record.is_null() {
return Err(unreadable(0));
}
let form = unsafe { record.cast::<u32>().read() };
if form != export::FORM {
return Err(unreadable(form));
}
let record = unsafe { &*record };
let text = |part: *const std::ffi::c_char| {
(!part.is_null())
.then(|| unsafe { std::ffi::CStr::from_ptr(part) }.to_string_lossy().into_owned())
};
match (text(record.compiler), text(record.plugin_api), text(record.contract)) {
(Some(compiler), Some(plugin_api), Some(contract)) => {
Ok(BuildDeclaration { compiler, plugin_api, contract, source: record.source })
}
_ => Err(unreadable(form)),
}
}
#[doc(hidden)]
pub mod export {
use std::ffi::{CStr, c_char};
pub use inventory;
pub const FORM: u32 = 1;
#[repr(C)]
pub struct ExportedBuildDeclaration {
pub form: u32,
pub compiler: *const c_char,
pub plugin_api: *const c_char,
pub contract: *const c_char,
pub source: u64,
}
unsafe impl Sync for ExportedBuildDeclaration {}
pub const COMPILER: &CStr = c_str(concat!(env!("RAZERI_PLUGIN_API_COMPILER"), "\0"));
pub const PLUGIN_API: &CStr = c_str(concat!(env!("CARGO_PKG_VERSION"), "\0"));
pub const CONTRACT: &CStr = c_str_from_bytes(&CONTRACT_WITH_NUL);
pub const SOURCE: u64 = fold(razeri_plugin_contract::SOURCE_FINGERPRINT, API_SOURCE);
const API_SOURCE: u64 = match u64::from_str_radix(env!("RAZERI_PLUGIN_API_SOURCE"), 16) {
Ok(fingerprint) => fingerprint,
Err(_) => panic!("the build script writes the fingerprint in hexadecimal"),
};
const CONTRACT_WITH_NUL: [u8; razeri_plugin_contract::VERSION.len() + 1] =
with_nul(razeri_plugin_contract::VERSION);
const fn with_nul<const N: usize>(text: &str) -> [u8; N] {
let bytes = text.as_bytes();
let mut out = [0; N];
let mut i = 0;
while i < bytes.len() {
out[i] = bytes[i];
i += 1;
}
out
}
const fn c_str(text: &'static str) -> &'static CStr {
c_str_from_bytes(text.as_bytes())
}
const fn c_str_from_bytes(bytes: &'static [u8]) -> &'static CStr {
match CStr::from_bytes_with_nul(bytes) {
Ok(text) => text,
Err(_) => panic!("a part of the build declaration holds a NUL"),
}
}
const fn fold(contract: u64, api: u64) -> u64 {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
let bytes = [contract.to_le_bytes(), api.to_le_bytes()];
let mut half = 0;
while half < 2 {
let mut i = 0;
while i < 8 {
hash ^= bytes[half][i] as u64;
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
i += 1;
}
half += 1;
}
hash
}
pub static THIS_BUILD: ExportedBuildDeclaration = ExportedBuildDeclaration {
form: FORM,
compiler: COMPILER.as_ptr(),
plugin_api: PLUGIN_API.as_ptr(),
contract: CONTRACT.as_ptr(),
source: SOURCE,
};
}
#[cfg(all(feature = "dynamic", not(target_arch = "wasm32")))]
mod loader {
use super::{
BuildDeclarationFn, PLUGIN_BUILD_SYMBOL, PLUGIN_REGISTER_SYMBOL, PluginLoadResult,
PluginRefusal, PluginRegistryBuilder, RegisterPluginFn, read_declaration,
};
use std::path::Path;
pub fn load_plugins_from_dir(
dir: &Path,
builder: &mut PluginRegistryBuilder,
) -> Vec<PluginLoadResult> {
warn_if_world_writable(dir);
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(e) => {
tracing::debug!("Cannot read plugin directory {}: {e}", dir.display());
return Vec::new();
}
};
let mut results = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if !is_shared_library(&path) {
continue;
}
results.push(load_single_plugin(&path, builder));
}
results
}
fn load_single_plugin(path: &Path, builder: &mut PluginRegistryBuilder) -> PluginLoadResult {
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown").to_string();
let lib = match unsafe { libloading::Library::new(path) } {
Ok(l) => l,
Err(e) => {
return PluginLoadResult::Failed {
path: path.to_path_buf(),
error: format!("Failed to load library: {e}"),
};
}
};
let declared = unsafe {
match lib.get::<BuildDeclarationFn>(PLUGIN_BUILD_SYMBOL.as_bytes()) {
Ok(declare) => read_declaration(declare()),
Err(_) => Err(PluginRefusal::Undeclared),
}
};
let refusal = match declared {
Ok(built_with) => PluginRefusal::for_declaration(built_with),
Err(refusal) => Some(refusal),
};
if let Some(refusal) = refusal {
drop(lib);
return PluginLoadResult::Refused { path: path.to_path_buf(), refusal };
}
let register_fn = unsafe {
match lib.get::<RegisterPluginFn>(PLUGIN_REGISTER_SYMBOL.as_bytes()) {
Ok(f) => *f,
Err(e) => {
return PluginLoadResult::Failed {
path: path.to_path_buf(),
error: format!("Missing symbol '{PLUGIN_REGISTER_SYMBOL}': {e}"),
};
}
}
};
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
unsafe { register_fn(builder) };
}));
std::mem::forget(lib);
match result {
Ok(()) => {
tracing::info!("Loaded plugin '{name}' from {}", path.display());
PluginLoadResult::Loaded { name, path: path.to_path_buf() }
}
Err(_) => PluginLoadResult::Failed {
path: path.to_path_buf(),
error: "Plugin registration panicked".to_string(),
},
}
}
fn is_shared_library(path: &Path) -> bool {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
matches!(ext, "so" | "dylib" | "dll")
}
#[cfg(unix)]
fn warn_if_world_writable(dir: &Path) {
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(dir) {
let mode = meta.permissions().mode();
if mode & 0o002 != 0 {
tracing::warn!(
"Plugin directory {} is world-writable — this is a security risk",
dir.display()
);
}
}
}
#[cfg(not(unix))]
fn warn_if_world_writable(_dir: &Path) {
}
}
#[cfg(all(feature = "dynamic", not(target_arch = "wasm32")))]
pub use loader::load_plugins_from_dir;
#[cfg(test)]
mod tests {
use super::export::{ExportedBuildDeclaration, FORM, THIS_BUILD};
use super::*;
fn this_build_but(change: impl FnOnce(&mut BuildDeclaration)) -> BuildDeclaration {
let mut declared = BuildDeclaration::this_build();
change(&mut declared);
declared
}
fn refusal_text(declared: BuildDeclaration) -> String {
PluginRefusal::for_declaration(declared)
.expect("a declaration that differs is refused")
.to_string()
}
#[test]
fn symbol_names_are_stable() {
assert_eq!(PLUGIN_REGISTER_SYMBOL, "razeri_plugin_register_v1");
assert_eq!(PLUGIN_BUILD_SYMBOL, "razeri_plugin_build");
}
#[test]
fn this_build_names_its_compiler_both_crate_versions_and_its_source() {
let this = BuildDeclaration::this_build();
assert!(this.compiler.starts_with("rustc "), "{this:?}");
assert_eq!(this.plugin_api, env!("CARGO_PKG_VERSION"));
assert_eq!(this.contract, razeri_plugin_contract::VERSION);
assert_ne!(this.source, 0, "{this:?}");
}
#[test]
fn the_exported_declaration_reads_back_as_this_build() {
let read = unsafe { read_declaration(&raw const THIS_BUILD) };
assert_eq!(read, Ok(BuildDeclaration::this_build()));
}
#[test]
fn a_declaration_identical_to_this_build_is_accepted() {
assert_eq!(PluginRefusal::for_declaration(BuildDeclaration::this_build()), None);
let this = BuildDeclaration::this_build();
assert!(this.differences(&this).is_empty());
}
#[test]
fn a_different_compiler_is_refused_naming_the_compiler() {
let needs = BuildDeclaration::this_build();
let declared = this_build_but(|d| d.compiler = "rustc 1.0.0 (elsewhere)".to_string());
assert_eq!(declared.differences(&needs), vec![BuildPart::Compiler]);
let text = refusal_text(declared);
assert!(text.contains("rustc 1.0.0 (elsewhere)"), "{text}");
assert!(text.contains(&needs.compiler), "{text}");
}
#[test]
fn a_different_plugin_api_version_is_refused_naming_it() {
let needs = BuildDeclaration::this_build();
let declared = this_build_but(|d| d.plugin_api = "0.0.1".to_string());
assert_eq!(declared.differences(&needs), vec![BuildPart::PluginApi]);
let text = refusal_text(declared);
assert!(text.contains("razeri-plugin-api 0.0.1"), "{text}");
assert!(text.contains(&format!("razeri-plugin-api {}", needs.plugin_api)), "{text}");
}
#[test]
fn a_different_contract_version_is_refused_naming_the_contract() {
let needs = BuildDeclaration::this_build();
let declared = this_build_but(|d| d.contract = "0.0.2".to_string());
assert_eq!(declared.differences(&needs), vec![BuildPart::Contract]);
let text = refusal_text(declared);
assert!(text.contains("razeri-plugin-contract 0.0.2"), "{text}");
assert!(text.contains(&format!("razeri-plugin-contract {}", needs.contract)), "{text}");
}
#[test]
fn the_same_versions_over_different_source_are_refused() {
let needs = BuildDeclaration::this_build();
let declared = this_build_but(|d| d.source = needs.source ^ 1);
assert_eq!(declared.differences(&needs), vec![BuildPart::Source]);
let text = refusal_text(declared);
assert!(text.contains(&format!("{:016x}", needs.source)), "{text}");
assert!(text.contains(&format!("{:016x}", needs.source ^ 1)), "{text}");
}
#[test]
fn every_part_that_differs_is_named() {
let needs = BuildDeclaration::this_build();
let declared = BuildDeclaration {
compiler: "rustc 1.0.0 (elsewhere)".to_string(),
plugin_api: "0.0.1".to_string(),
contract: "0.0.2".to_string(),
source: needs.source ^ 1,
};
assert_eq!(
declared.differences(&needs),
vec![BuildPart::Compiler, BuildPart::PluginApi, BuildPart::Contract]
);
}
#[test]
fn every_refusal_says_what_to_do_first_and_names_both_ways() {
let needs = BuildDeclaration::this_build();
let refusals = [
PluginRefusal::Undeclared,
PluginRefusal::Unreadable { form: FORM + 1 },
PluginRefusal::for_declaration(this_build_but(|d| d.compiler = "rustc 1.0.0".into()))
.expect("refused"),
];
for refusal in refusals {
let text = refusal.to_string();
assert!(text.starts_with("Rebuild it "), "the fix is not first: {text}");
assert!(text.contains(&needs.compiler), "{text}");
assert!(text.contains(&format!("razeri-plugin-api {}", needs.plugin_api)), "{text}");
assert!(text.contains(&format!("razeri-plugin-contract {}", needs.contract)), "{text}");
assert!(text.contains("link it into a razeri you build"), "{text}");
assert!(text.contains(LINKING_GUIDE), "{text}");
for internal in ["BuildDeclaration", "PluginRefusal", "razeri_plugin_build"] {
assert!(!text.contains(internal), "names {internal}: {text}");
}
}
}
#[test]
fn a_plugin_that_declares_nothing_is_told_so_and_how_to_export_one() {
let text = PluginRefusal::Undeclared.to_string();
assert!(text.contains("does not say how it was built"), "{text}");
assert!(text.contains("export_plugin!"), "{text}");
}
#[test]
fn a_declaration_in_a_form_this_razeri_does_not_read_is_refused() {
let later = ExportedBuildDeclaration {
form: FORM + 1,
compiler: std::ptr::null(),
plugin_api: std::ptr::null(),
contract: std::ptr::null(),
source: 0,
};
let read = unsafe { read_declaration(&raw const later) };
assert_eq!(read, Err(PluginRefusal::Unreadable { form: FORM + 1 }));
let text = read.unwrap_err().to_string();
assert!(text.contains(&format!("form {}", FORM + 1)), "{text}");
}
#[test]
fn a_record_with_a_missing_part_is_refused_as_unreadable() {
let broken = ExportedBuildDeclaration {
form: FORM,
compiler: std::ptr::null(),
plugin_api: c"0.0.1".as_ptr(),
contract: c"0.0.2".as_ptr(),
source: 1,
};
let read = unsafe { read_declaration(&raw const broken) };
assert!(matches!(read, Err(PluginRefusal::Unreadable { form: FORM, .. })), "{read:?}");
}
#[test]
fn no_record_at_all_is_refused_as_unreadable() {
let read = unsafe { read_declaration(std::ptr::null()) };
assert!(matches!(read, Err(PluginRefusal::Unreadable { .. })), "{read:?}");
}
#[cfg(all(feature = "dynamic", not(target_arch = "wasm32")))]
mod dynamic_tests {
use super::*;
use std::path::Path;
#[test]
fn load_from_nonexistent_dir_returns_empty() {
let mut builder = PluginRegistryBuilder::new();
let results = load_plugins_from_dir(
Path::new("/tmp/razeri-nonexistent-plugin-dir-12345"),
&mut builder,
);
assert!(results.is_empty());
}
#[test]
fn load_from_empty_dir_returns_empty() {
let dir = tempfile::tempdir().unwrap();
let mut builder = PluginRegistryBuilder::new();
let results = load_plugins_from_dir(dir.path(), &mut builder);
assert!(results.is_empty());
}
#[test]
fn load_skips_non_library_files() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("notes.txt"), "hello").unwrap();
std::fs::write(dir.path().join("data.json"), "{}").unwrap();
let mut builder = PluginRegistryBuilder::new();
let results = load_plugins_from_dir(dir.path(), &mut builder);
assert!(results.is_empty());
}
#[test]
fn load_invalid_so_returns_failed() {
let dir = tempfile::tempdir().unwrap();
let lib_name = if cfg!(target_os = "macos") {
"bad.dylib"
} else if cfg!(target_os = "windows") {
"bad.dll"
} else {
"bad.so"
};
std::fs::write(dir.path().join(lib_name), "not a real library").unwrap();
let mut builder = PluginRegistryBuilder::new();
let results = load_plugins_from_dir(dir.path(), &mut builder);
assert_eq!(results.len(), 1);
assert!(
matches!(&results[0], PluginLoadResult::Failed { error, .. } if error.contains("Failed to load library"))
);
}
}
}