Skip to main content

razeri_plugin_api/
dynamic.rs

1// Copyright (C) 2026 Gabriel Shea
2// Licensed under either of the Apache License, Version 2.0 or the MIT
3// license, at your option.
4
5//! Loading plugins built as shared libraries.
6//!
7//! A plugin compiled as a shared library (`.so`, `.dylib`, `.dll`) is loaded
8//! from a directory at start-up by [`load_plugins_from_dir`]. The library and
9//! the razeri that loads it then share Rust types: the dialect traits, the
10//! registry builder, and every value passed between them. Rust lays those
11//! types out the same way on both sides only when both sides are the same
12//! build — the same compiler, and the same source of `razeri-plugin-api` and
13//! `razeri-plugin-contract`. Anything else reads memory the wrong way, and the
14//! process crashes rather than failing.
15//!
16//! So a plugin says how it was built, in a [`BuildDeclaration`], and says it
17//! through a door every compiler reads alike: a C function returning a
18//! `#[repr(C)]` record of C strings and one number. The loader reads that
19//! first. A library whose declaration differs from this razeri's own in any
20//! part, or that declares nothing, is refused with a [`PluginRefusal`] that
21//! names what it was built with and what it needs, and none of its Rust code
22//! is run.
23//!
24//! A plugin exports its declaration and its registration with one line,
25//! [`export_plugin!`](crate::export_plugin). The same line lets the crate be
26//! linked into a razeri you build yourself instead, which needs no match at
27//! all because there is only one build.
28//!
29//! ## What else the loader does
30//!
31//! - **Panics stay in the plugin**: registration runs inside `catch_unwind`.
32//! - **Libraries stay loaded**: a library whose registration ran is never
33//!   unloaded, because what it registered lives as long as the process. A
34//!   refused library is unloaded at once; nothing of it was kept.
35//! - **An open directory is named**: on Unix, a plugin directory anyone can
36//!   write to is reported in the log.
37
38use crate::builder::PluginRegistryBuilder;
39use std::fmt;
40
41/// Where a plugin author reads how to link a dialect into a razeri they build
42/// — the other way to run a plugin, and the one every refusal offers. It names
43/// the page inside the book each release carries, because the book has no
44/// public address.
45pub const LINKING_GUIDE: &str = "plugins/linking-a-dialect.html in the razeri book";
46
47/// Symbol name for the plugin registration function.
48///
49/// The function signature is [`RegisterPluginFn`]. It is called only after
50/// the library's [`BuildDeclaration`] matches this razeri's.
51pub const PLUGIN_REGISTER_SYMBOL: &str = "razeri_plugin_register_v1";
52
53/// Symbol name for the function that says how a plugin was built.
54///
55/// The function signature is [`BuildDeclarationFn`]. A library without it is
56/// refused.
57pub const PLUGIN_BUILD_SYMBOL: &str = "razeri_plugin_build";
58
59/// Type signature for the plugin registration entry point.
60///
61/// # Safety
62///
63/// The `builder` pointer must be valid and outlive the call, and the library
64/// must have been built exactly as the caller was.
65pub type RegisterPluginFn = unsafe fn(&mut PluginRegistryBuilder);
66
67/// Type signature for the function that says how a plugin was built.
68///
69/// It uses the C calling convention and returns a pointer to a `#[repr(C)]`
70/// record, so a library built by any compiler answers it the same way.
71pub type BuildDeclarationFn = extern "C" fn() -> *const export::ExportedBuildDeclaration;
72
73/// How a plugin was built: the compiler, and the two plugin crates it was
74/// compiled against.
75///
76/// A plugin loaded from a directory is accepted only when its declaration is
77/// equal to [`BuildDeclaration::this_build`] in every part.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct BuildDeclaration {
80    /// The compiler, as `rustc -V` names it, such as
81    /// `rustc 1.93.1 (01f6ddf75 2026-02-11)`.
82    pub compiler: String,
83    /// The version of `razeri-plugin-api`.
84    pub plugin_api: String,
85    /// The version of `razeri-plugin-contract`.
86    pub contract: String,
87    /// A fingerprint of the source of both crates. Two builds carrying the
88    /// same version numbers over different source have different
89    /// fingerprints.
90    pub source: u64,
91}
92
93/// One part of a [`BuildDeclaration`], named so a refusal can say which parts
94/// differ.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum BuildPart {
97    /// The compiler.
98    Compiler,
99    /// The version of `razeri-plugin-api`.
100    PluginApi,
101    /// The version of `razeri-plugin-contract`.
102    Contract,
103    /// The source of the two crates, when their versions agree.
104    Source,
105}
106
107impl BuildDeclaration {
108    /// How this build of `razeri-plugin-api` was made — the declaration a host
109    /// compares plugins against, and the one [`export_plugin!`](crate::export_plugin)
110    /// exports from a plugin.
111    #[must_use]
112    pub fn this_build() -> Self {
113        Self {
114            compiler: export::COMPILER.to_string_lossy().into_owned(),
115            plugin_api: export::PLUGIN_API.to_string_lossy().into_owned(),
116            contract: export::CONTRACT.to_string_lossy().into_owned(),
117            source: export::SOURCE,
118        }
119    }
120
121    /// The parts in which `self` differs from `needs`, in the order a reader
122    /// meets them. Empty when the two are the same build.
123    #[must_use]
124    pub fn differences(&self, needs: &Self) -> Vec<BuildPart> {
125        let mut parts = Vec::new();
126        if self.compiler != needs.compiler {
127            parts.push(BuildPart::Compiler);
128        }
129        if self.plugin_api != needs.plugin_api {
130            parts.push(BuildPart::PluginApi);
131        }
132        if self.contract != needs.contract {
133            parts.push(BuildPart::Contract);
134        }
135        // Another version is another source; the fingerprint is news only
136        // when both versions agree.
137        let versions_agree = self.plugin_api == needs.plugin_api && self.contract == needs.contract;
138        if versions_agree && self.source != needs.source {
139            parts.push(BuildPart::Source);
140        }
141        parts
142    }
143}
144
145impl fmt::Display for BuildDeclaration {
146    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147        write!(
148            f,
149            "{} against razeri-plugin-api {} and razeri-plugin-contract {} (source {:016x})",
150            self.compiler, self.plugin_api, self.contract, self.source
151        )
152    }
153}
154
155/// Why the loader would not load a plugin library.
156///
157/// A refusal is always this razeri's: its message says what to rebuild the
158/// plugin with from [`BuildDeclaration::this_build`].
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub enum PluginRefusal {
161    /// The library does not say how it was built: it has no
162    /// [`PLUGIN_BUILD_SYMBOL`]. Every plugin exported without
163    /// [`export_plugin!`](crate::export_plugin) is one of these.
164    Undeclared,
165    /// The library says how it was built in a form this razeri cannot read.
166    Unreadable {
167        /// The form number the library declared.
168        form: u32,
169    },
170    /// The library was built differently from this razeri.
171    BuiltDifferently {
172        /// How the library was built.
173        built_with: BuildDeclaration,
174    },
175}
176
177impl PluginRefusal {
178    /// The refusal a plugin declaring `built_with` receives from this razeri,
179    /// or `None` when it was built exactly as this razeri was.
180    #[must_use]
181    pub fn for_declaration(built_with: BuildDeclaration) -> Option<Self> {
182        if built_with.differences(&BuildDeclaration::this_build()).is_empty() {
183            None
184        } else {
185            Some(Self::BuiltDifferently { built_with })
186        }
187    }
188}
189
190impl fmt::Display for PluginRefusal {
191    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
192        let needs = BuildDeclaration::this_build();
193
194        // What to do, first.
195        let only_the_source_differs = matches!(
196            self,
197            Self::BuiltDifferently { built_with }
198                if built_with.differences(&needs) == [BuildPart::Source]
199        );
200        if only_the_source_differs {
201            write!(
202                f,
203                "Rebuild it with {} against the same source of razeri-plugin-api {} and \
204                 razeri-plugin-contract {} this razeri was built from (source {:016x})",
205                needs.compiler, needs.plugin_api, needs.contract, needs.source
206            )?;
207        } else {
208            write!(
209                f,
210                "Rebuild it with {} against razeri-plugin-api {} and razeri-plugin-contract {}",
211                needs.compiler, needs.plugin_api, needs.contract
212            )?;
213        }
214        if matches!(self, Self::Undeclared) {
215            f.write_str(", exporting it with razeri_plugin_api::export_plugin!")?;
216        }
217        write!(f, ", or link it into a razeri you build yourself ({LINKING_GUIDE}). ")?;
218
219        // Then why.
220        match self {
221            Self::Undeclared => f.write_str("It does not say how it was built")?,
222            Self::Unreadable { form, .. } => write!(
223                f,
224                "It says how it was built in a form this razeri does not read (form {form})"
225            )?,
226            Self::BuiltDifferently { built_with } => {
227                write!(f, "It was built {}", built_differently(built_with, &needs))?;
228            }
229        }
230        f.write_str(
231            ", and a plugin loaded from a directory must be built exactly as the razeri that \
232             loads it.",
233        )
234    }
235}
236
237impl std::error::Error for PluginRefusal {}
238
239/// The parts of `built_with` that differ from `needs`, as the end of the
240/// sentence "It was built …".
241fn built_differently(built_with: &BuildDeclaration, needs: &BuildDeclaration) -> String {
242    let parts = built_with.differences(needs);
243    let mut clauses = Vec::new();
244    if parts.contains(&BuildPart::Compiler) {
245        clauses.push(format!("with {}", built_with.compiler));
246    }
247    let mut against = Vec::new();
248    if parts.contains(&BuildPart::PluginApi) {
249        against.push(format!("razeri-plugin-api {}", built_with.plugin_api));
250    }
251    if parts.contains(&BuildPart::Contract) {
252        against.push(format!("razeri-plugin-contract {}", built_with.contract));
253    }
254    if !against.is_empty() {
255        clauses.push(format!("against {}", against.join(" and ")));
256    }
257    if parts.contains(&BuildPart::Source) {
258        clauses.push(format!(
259            "against another source of those two versions (source {:016x})",
260            built_with.source
261        ));
262    }
263    clauses.join(" ")
264}
265
266/// Outcome of loading a single plugin library.
267#[derive(Debug)]
268pub enum PluginLoadResult {
269    /// Plugin loaded and registered successfully.
270    Loaded {
271        /// Display name (file stem).
272        name: String,
273        /// Path to the shared library.
274        path: std::path::PathBuf,
275    },
276    /// The library was found and not loaded, because it was not built as
277    /// this razeri was. None of its Rust code ran.
278    Refused {
279        /// Path to the shared library.
280        path: std::path::PathBuf,
281        /// Why it was refused.
282        refusal: PluginRefusal,
283    },
284    /// Library could not be loaded or registration failed.
285    Failed {
286        /// Path to the shared library.
287        path: std::path::PathBuf,
288        /// Error description.
289        error: String,
290    },
291}
292
293/// Read a declaration a library exported.
294///
295/// # Errors
296///
297/// A [`PluginRefusal::Unreadable`] when the record is in a form this razeri
298/// does not read, or a string in it is missing.
299///
300/// # Safety
301///
302/// `record` must be null or point to a live [`export::ExportedBuildDeclaration`]
303/// whose string pointers are null or point to NUL-terminated strings.
304pub unsafe fn read_declaration(
305    record: *const export::ExportedBuildDeclaration,
306) -> Result<BuildDeclaration, PluginRefusal> {
307    let unreadable = |form| PluginRefusal::Unreadable { form };
308    if record.is_null() {
309        return Err(unreadable(0));
310    }
311    // The form comes first and is read on its own: a record in another form
312    // may be laid out differently after it.
313    // SAFETY: the caller promises a live record, which begins with its form.
314    let form = unsafe { record.cast::<u32>().read() };
315    if form != export::FORM {
316        return Err(unreadable(form));
317    }
318    // SAFETY: a live record in the form this crate writes.
319    let record = unsafe { &*record };
320    // SAFETY: the caller promises each string pointer is null or NUL-terminated.
321    let text = |part: *const std::ffi::c_char| {
322        (!part.is_null())
323            .then(|| unsafe { std::ffi::CStr::from_ptr(part) }.to_string_lossy().into_owned())
324    };
325    match (text(record.compiler), text(record.plugin_api), text(record.contract)) {
326        (Some(compiler), Some(plugin_api), Some(contract)) => {
327            Ok(BuildDeclaration { compiler, plugin_api, contract, source: record.source })
328        }
329        _ => Err(unreadable(form)),
330    }
331}
332
333/// What [`export_plugin!`](crate::export_plugin) expands to reaches through
334/// here. Not for use by hand.
335#[doc(hidden)]
336pub mod export {
337    use std::ffi::{CStr, c_char};
338
339    pub use inventory;
340
341    /// The form of [`ExportedBuildDeclaration`] this crate writes and reads.
342    /// A record declaring any other form is refused as unreadable.
343    pub const FORM: u32 = 1;
344
345    /// A [`BuildDeclaration`](super::BuildDeclaration) as it crosses the
346    /// library boundary: C strings and one number, laid out as C lays them
347    /// out, with the form first so a reader knows how to read the rest.
348    /// A later form may add fields after these; it never moves one.
349    #[repr(C)]
350    pub struct ExportedBuildDeclaration {
351        /// Which form of this record follows.
352        pub form: u32,
353        /// The compiler, NUL-terminated.
354        pub compiler: *const c_char,
355        /// The version of `razeri-plugin-api`, NUL-terminated.
356        pub plugin_api: *const c_char,
357        /// The version of `razeri-plugin-contract`, NUL-terminated.
358        pub contract: *const c_char,
359        /// The fingerprint of both crates' source.
360        pub source: u64,
361    }
362
363    // SAFETY: every pointer in the record points to a string that lives for
364    // the life of the program and is never written.
365    unsafe impl Sync for ExportedBuildDeclaration {}
366
367    /// The compiler this crate was built with, as `rustc -V` names it. The
368    /// build script asks the compiler cargo is building with.
369    pub const COMPILER: &CStr = c_str(concat!(env!("RAZERI_PLUGIN_API_COMPILER"), "\0"));
370
371    /// This crate's version.
372    pub const PLUGIN_API: &CStr = c_str(concat!(env!("CARGO_PKG_VERSION"), "\0"));
373
374    /// The version of `razeri-plugin-contract` this crate was built against.
375    pub const CONTRACT: &CStr = c_str_from_bytes(&CONTRACT_WITH_NUL);
376
377    /// The fingerprint of both crates' source: the contract's, then this
378    /// crate's, folded into one number.
379    pub const SOURCE: u64 = fold(razeri_plugin_contract::SOURCE_FINGERPRINT, API_SOURCE);
380
381    /// This crate's own source fingerprint, from its build script.
382    const API_SOURCE: u64 = match u64::from_str_radix(env!("RAZERI_PLUGIN_API_SOURCE"), 16) {
383        Ok(fingerprint) => fingerprint,
384        Err(_) => panic!("the build script writes the fingerprint in hexadecimal"),
385    };
386
387    /// The contract's version with the NUL a C string ends in.
388    const CONTRACT_WITH_NUL: [u8; razeri_plugin_contract::VERSION.len() + 1] =
389        with_nul(razeri_plugin_contract::VERSION);
390
391    const fn with_nul<const N: usize>(text: &str) -> [u8; N] {
392        let bytes = text.as_bytes();
393        let mut out = [0; N];
394        let mut i = 0;
395        while i < bytes.len() {
396            out[i] = bytes[i];
397            i += 1;
398        }
399        out
400    }
401
402    const fn c_str(text: &'static str) -> &'static CStr {
403        c_str_from_bytes(text.as_bytes())
404    }
405
406    const fn c_str_from_bytes(bytes: &'static [u8]) -> &'static CStr {
407        match CStr::from_bytes_with_nul(bytes) {
408            Ok(text) => text,
409            Err(_) => panic!("a part of the build declaration holds a NUL"),
410        }
411    }
412
413    /// FNV-1a over the two fingerprints' bytes, the contract's first.
414    const fn fold(contract: u64, api: u64) -> u64 {
415        let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
416        let bytes = [contract.to_le_bytes(), api.to_le_bytes()];
417        let mut half = 0;
418        while half < 2 {
419            let mut i = 0;
420            while i < 8 {
421                hash ^= bytes[half][i] as u64;
422                hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
423                i += 1;
424            }
425            half += 1;
426        }
427        hash
428    }
429
430    /// This build's declaration, the record a plugin exports.
431    pub static THIS_BUILD: ExportedBuildDeclaration = ExportedBuildDeclaration {
432        form: FORM,
433        compiler: COMPILER.as_ptr(),
434        plugin_api: PLUGIN_API.as_ptr(),
435        contract: CONTRACT.as_ptr(),
436        source: SOURCE,
437    };
438}
439
440// ── Feature-gated loading implementation ────────────────────────────
441
442// Dynamic library loading (`libloading`/dlopen) is native-only: there is no
443// shared-library loading on wasm32-unknown-unknown, and `libloading` is not a
444// dependency of the wasm build. The `dynamic` feature is a no-op on wasm.
445#[cfg(all(feature = "dynamic", not(target_arch = "wasm32")))]
446mod loader {
447    use super::{
448        BuildDeclarationFn, PLUGIN_BUILD_SYMBOL, PLUGIN_REGISTER_SYMBOL, PluginLoadResult,
449        PluginRefusal, PluginRegistryBuilder, RegisterPluginFn, read_declaration,
450    };
451    use std::path::Path;
452
453    /// Load all plugin shared libraries from `dir` and register them
454    /// with `builder`.
455    ///
456    /// Scans `dir` for files with the platform-appropriate extension
457    /// (`.so`, `.dylib`, `.dll`). Each library is loaded, its build
458    /// declaration is compared with this razeri's, and its registration
459    /// function is called only when the two match.
460    ///
461    /// Returns one [`PluginLoadResult`] per library file found.
462    pub fn load_plugins_from_dir(
463        dir: &Path,
464        builder: &mut PluginRegistryBuilder,
465    ) -> Vec<PluginLoadResult> {
466        warn_if_world_writable(dir);
467
468        let entries = match std::fs::read_dir(dir) {
469            Ok(e) => e,
470            Err(e) => {
471                tracing::debug!("Cannot read plugin directory {}: {e}", dir.display());
472                return Vec::new();
473            }
474        };
475
476        let mut results = Vec::new();
477
478        for entry in entries.flatten() {
479            let path = entry.path();
480            if !is_shared_library(&path) {
481                continue;
482            }
483            results.push(load_single_plugin(&path, builder));
484        }
485
486        results
487    }
488
489    /// Load a single shared library plugin.
490    fn load_single_plugin(path: &Path, builder: &mut PluginRegistryBuilder) -> PluginLoadResult {
491        let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown").to_string();
492
493        // SAFETY: opening a library runs its initialisers, which is the one
494        // thing done before its declaration is read. A file in a plugin
495        // directory is trusted that far and no further.
496        let lib = match unsafe { libloading::Library::new(path) } {
497            Ok(l) => l,
498            Err(e) => {
499                return PluginLoadResult::Failed {
500                    path: path.to_path_buf(),
501                    error: format!("Failed to load library: {e}"),
502                };
503            }
504        };
505
506        // How the library was built, read through its C door. Nothing else
507        // in it is called until this matches this razeri's own build.
508        // SAFETY: the symbol, when present, has the C signature every
509        // compiler agrees on, and returns a record `read_declaration` reads
510        // through its C layout.
511        let declared = unsafe {
512            match lib.get::<BuildDeclarationFn>(PLUGIN_BUILD_SYMBOL.as_bytes()) {
513                Ok(declare) => read_declaration(declare()),
514                Err(_) => Err(PluginRefusal::Undeclared),
515            }
516        };
517        let refusal = match declared {
518            Ok(built_with) => PluginRefusal::for_declaration(built_with),
519            Err(refusal) => Some(refusal),
520        };
521        if let Some(refusal) = refusal {
522            // Nothing the library holds was handed to razeri, so it can go.
523            drop(lib);
524            return PluginLoadResult::Refused { path: path.to_path_buf(), refusal };
525        }
526
527        // SAFETY: the library was built exactly as this razeri was, so the
528        // registration's Rust signature means the same thing on both sides.
529        let register_fn = unsafe {
530            match lib.get::<RegisterPluginFn>(PLUGIN_REGISTER_SYMBOL.as_bytes()) {
531                Ok(f) => *f,
532                Err(e) => {
533                    return PluginLoadResult::Failed {
534                        path: path.to_path_buf(),
535                        error: format!("Missing symbol '{PLUGIN_REGISTER_SYMBOL}': {e}"),
536                    };
537                }
538            }
539        };
540
541        // Call registration wrapped in catch_unwind for safety.
542        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
543            // SAFETY: the builder outlives the call, and the build matched.
544            unsafe { register_fn(builder) };
545        }));
546
547        // Once the registration has run, the library stays loaded for the
548        // life of the process, whatever it did: anything it registered before
549        // a panic points into it, and unloading it would leave those behind.
550        std::mem::forget(lib);
551        match result {
552            Ok(()) => {
553                tracing::info!("Loaded plugin '{name}' from {}", path.display());
554                PluginLoadResult::Loaded { name, path: path.to_path_buf() }
555            }
556            Err(_) => PluginLoadResult::Failed {
557                path: path.to_path_buf(),
558                error: "Plugin registration panicked".to_string(),
559            },
560        }
561    }
562
563    /// Check if a path has a shared-library extension.
564    fn is_shared_library(path: &Path) -> bool {
565        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
566        matches!(ext, "so" | "dylib" | "dll")
567    }
568
569    /// Emit a warning if the directory is world-writable (Unix only).
570    #[cfg(unix)]
571    fn warn_if_world_writable(dir: &Path) {
572        use std::os::unix::fs::PermissionsExt;
573        if let Ok(meta) = std::fs::metadata(dir) {
574            let mode = meta.permissions().mode();
575            if mode & 0o002 != 0 {
576                tracing::warn!(
577                    "Plugin directory {} is world-writable — this is a security risk",
578                    dir.display()
579                );
580            }
581        }
582    }
583
584    #[cfg(not(unix))]
585    fn warn_if_world_writable(_dir: &Path) {
586        // No-op on non-Unix platforms.
587    }
588}
589
590#[cfg(all(feature = "dynamic", not(target_arch = "wasm32")))]
591pub use loader::load_plugins_from_dir;
592
593// ── Tests ────────────────────────────────────────────────────────────
594
595#[cfg(test)]
596mod tests {
597    use super::export::{ExportedBuildDeclaration, FORM, THIS_BUILD};
598    use super::*;
599
600    /// A declaration that differs from this build only where the test says.
601    fn this_build_but(change: impl FnOnce(&mut BuildDeclaration)) -> BuildDeclaration {
602        let mut declared = BuildDeclaration::this_build();
603        change(&mut declared);
604        declared
605    }
606
607    /// The message a plugin declaring `declared` is refused with.
608    fn refusal_text(declared: BuildDeclaration) -> String {
609        PluginRefusal::for_declaration(declared)
610            .expect("a declaration that differs is refused")
611            .to_string()
612    }
613
614    #[test]
615    fn symbol_names_are_stable() {
616        assert_eq!(PLUGIN_REGISTER_SYMBOL, "razeri_plugin_register_v1");
617        assert_eq!(PLUGIN_BUILD_SYMBOL, "razeri_plugin_build");
618    }
619
620    /// **This build names its compiler, both crates and their source.** A
621    /// declaration with an empty part would match any other with the same
622    /// empty part, which is the silence the declaration exists to end.
623    #[test]
624    fn this_build_names_its_compiler_both_crate_versions_and_its_source() {
625        let this = BuildDeclaration::this_build();
626        assert!(this.compiler.starts_with("rustc "), "{this:?}");
627        assert_eq!(this.plugin_api, env!("CARGO_PKG_VERSION"));
628        assert_eq!(this.contract, razeri_plugin_contract::VERSION);
629        assert_ne!(this.source, 0, "{this:?}");
630    }
631
632    /// **What a plugin exports reads back, through its C layout, as the build
633    /// that made it.**
634    #[test]
635    fn the_exported_declaration_reads_back_as_this_build() {
636        // SAFETY: THIS_BUILD is a live record of static C strings.
637        let read = unsafe { read_declaration(&raw const THIS_BUILD) };
638        assert_eq!(read, Ok(BuildDeclaration::this_build()));
639    }
640
641    #[test]
642    fn a_declaration_identical_to_this_build_is_accepted() {
643        assert_eq!(PluginRefusal::for_declaration(BuildDeclaration::this_build()), None);
644        let this = BuildDeclaration::this_build();
645        assert!(this.differences(&this).is_empty());
646    }
647
648    /// **Another compiler is refused, and the refusal names both.** A newer
649    /// compiler over the same source is enough to crash a process that calls
650    /// into the library, so the compiler is compared on its own.
651    #[test]
652    fn a_different_compiler_is_refused_naming_the_compiler() {
653        let needs = BuildDeclaration::this_build();
654        let declared = this_build_but(|d| d.compiler = "rustc 1.0.0 (elsewhere)".to_string());
655        assert_eq!(declared.differences(&needs), vec![BuildPart::Compiler]);
656
657        let text = refusal_text(declared);
658        assert!(text.contains("rustc 1.0.0 (elsewhere)"), "{text}");
659        assert!(text.contains(&needs.compiler), "{text}");
660    }
661
662    #[test]
663    fn a_different_plugin_api_version_is_refused_naming_it() {
664        let needs = BuildDeclaration::this_build();
665        let declared = this_build_but(|d| d.plugin_api = "0.0.1".to_string());
666        assert_eq!(declared.differences(&needs), vec![BuildPart::PluginApi]);
667
668        let text = refusal_text(declared);
669        assert!(text.contains("razeri-plugin-api 0.0.1"), "{text}");
670        assert!(text.contains(&format!("razeri-plugin-api {}", needs.plugin_api)), "{text}");
671    }
672
673    #[test]
674    fn a_different_contract_version_is_refused_naming_the_contract() {
675        let needs = BuildDeclaration::this_build();
676        let declared = this_build_but(|d| d.contract = "0.0.2".to_string());
677        assert_eq!(declared.differences(&needs), vec![BuildPart::Contract]);
678
679        let text = refusal_text(declared);
680        assert!(text.contains("razeri-plugin-contract 0.0.2"), "{text}");
681        assert!(text.contains(&format!("razeri-plugin-contract {}", needs.contract)), "{text}");
682    }
683
684    /// **The same version numbers over different source are refused.** A
685    /// crate's version moves when it is published, not when its source does,
686    /// so a plugin built against the published crates and a razeri built from
687    /// newer source can carry the same numbers and different types.
688    #[test]
689    fn the_same_versions_over_different_source_are_refused() {
690        let needs = BuildDeclaration::this_build();
691        let declared = this_build_but(|d| d.source = needs.source ^ 1);
692        assert_eq!(declared.differences(&needs), vec![BuildPart::Source]);
693
694        let text = refusal_text(declared);
695        assert!(text.contains(&format!("{:016x}", needs.source)), "{text}");
696        assert!(text.contains(&format!("{:016x}", needs.source ^ 1)), "{text}");
697    }
698
699    /// Every part that differs is named, in the order a reader meets them.
700    #[test]
701    fn every_part_that_differs_is_named() {
702        let needs = BuildDeclaration::this_build();
703        let declared = BuildDeclaration {
704            compiler: "rustc 1.0.0 (elsewhere)".to_string(),
705            plugin_api: "0.0.1".to_string(),
706            contract: "0.0.2".to_string(),
707            source: needs.source ^ 1,
708        };
709        assert_eq!(
710            declared.differences(&needs),
711            vec![BuildPart::Compiler, BuildPart::PluginApi, BuildPart::Contract]
712        );
713    }
714
715    /// **The fix comes first.** Every refusal opens with what to do, names
716    /// both ways to do it, and never names a type or a symbol the reader did
717    /// not write.
718    #[test]
719    fn every_refusal_says_what_to_do_first_and_names_both_ways() {
720        let needs = BuildDeclaration::this_build();
721        let refusals = [
722            PluginRefusal::Undeclared,
723            PluginRefusal::Unreadable { form: FORM + 1 },
724            PluginRefusal::for_declaration(this_build_but(|d| d.compiler = "rustc 1.0.0".into()))
725                .expect("refused"),
726        ];
727        for refusal in refusals {
728            let text = refusal.to_string();
729            assert!(text.starts_with("Rebuild it "), "the fix is not first: {text}");
730            assert!(text.contains(&needs.compiler), "{text}");
731            assert!(text.contains(&format!("razeri-plugin-api {}", needs.plugin_api)), "{text}");
732            assert!(text.contains(&format!("razeri-plugin-contract {}", needs.contract)), "{text}");
733            assert!(text.contains("link it into a razeri you build"), "{text}");
734            assert!(text.contains(LINKING_GUIDE), "{text}");
735            for internal in ["BuildDeclaration", "PluginRefusal", "razeri_plugin_build"] {
736                assert!(!text.contains(internal), "names {internal}: {text}");
737            }
738        }
739    }
740
741    #[test]
742    fn a_plugin_that_declares_nothing_is_told_so_and_how_to_export_one() {
743        let text = PluginRefusal::Undeclared.to_string();
744        assert!(text.contains("does not say how it was built"), "{text}");
745        assert!(text.contains("export_plugin!"), "{text}");
746    }
747
748    /// **A form this razeri does not read is refused, not guessed at.**
749    #[test]
750    fn a_declaration_in_a_form_this_razeri_does_not_read_is_refused() {
751        let later = ExportedBuildDeclaration {
752            form: FORM + 1,
753            compiler: std::ptr::null(),
754            plugin_api: std::ptr::null(),
755            contract: std::ptr::null(),
756            source: 0,
757        };
758        // SAFETY: a live record; its null strings are never read for an
759        // unknown form.
760        let read = unsafe { read_declaration(&raw const later) };
761        assert_eq!(read, Err(PluginRefusal::Unreadable { form: FORM + 1 }));
762        let text = read.unwrap_err().to_string();
763        assert!(text.contains(&format!("form {}", FORM + 1)), "{text}");
764    }
765
766    #[test]
767    fn a_record_with_a_missing_part_is_refused_as_unreadable() {
768        let broken = ExportedBuildDeclaration {
769            form: FORM,
770            compiler: std::ptr::null(),
771            plugin_api: c"0.0.1".as_ptr(),
772            contract: c"0.0.2".as_ptr(),
773            source: 1,
774        };
775        // SAFETY: a live record whose strings are null or static.
776        let read = unsafe { read_declaration(&raw const broken) };
777        assert!(matches!(read, Err(PluginRefusal::Unreadable { form: FORM, .. })), "{read:?}");
778    }
779
780    #[test]
781    fn no_record_at_all_is_refused_as_unreadable() {
782        // SAFETY: null is allowed and never dereferenced.
783        let read = unsafe { read_declaration(std::ptr::null()) };
784        assert!(matches!(read, Err(PluginRefusal::Unreadable { .. })), "{read:?}");
785    }
786
787    #[cfg(all(feature = "dynamic", not(target_arch = "wasm32")))]
788    mod dynamic_tests {
789        use super::*;
790        use std::path::Path;
791
792        #[test]
793        fn load_from_nonexistent_dir_returns_empty() {
794            let mut builder = PluginRegistryBuilder::new();
795            let results = load_plugins_from_dir(
796                Path::new("/tmp/razeri-nonexistent-plugin-dir-12345"),
797                &mut builder,
798            );
799            assert!(results.is_empty());
800        }
801
802        #[test]
803        fn load_from_empty_dir_returns_empty() {
804            let dir = tempfile::tempdir().unwrap();
805            let mut builder = PluginRegistryBuilder::new();
806            let results = load_plugins_from_dir(dir.path(), &mut builder);
807            assert!(results.is_empty());
808        }
809
810        #[test]
811        fn load_skips_non_library_files() {
812            let dir = tempfile::tempdir().unwrap();
813            std::fs::write(dir.path().join("notes.txt"), "hello").unwrap();
814            std::fs::write(dir.path().join("data.json"), "{}").unwrap();
815
816            let mut builder = PluginRegistryBuilder::new();
817            let results = load_plugins_from_dir(dir.path(), &mut builder);
818            assert!(results.is_empty());
819        }
820
821        #[test]
822        fn load_invalid_so_returns_failed() {
823            let dir = tempfile::tempdir().unwrap();
824            let lib_name = if cfg!(target_os = "macos") {
825                "bad.dylib"
826            } else if cfg!(target_os = "windows") {
827                "bad.dll"
828            } else {
829                "bad.so"
830            };
831            std::fs::write(dir.path().join(lib_name), "not a real library").unwrap();
832
833            let mut builder = PluginRegistryBuilder::new();
834            let results = load_plugins_from_dir(dir.path(), &mut builder);
835            assert_eq!(results.len(), 1);
836            assert!(
837                matches!(&results[0], PluginLoadResult::Failed { error, .. } if error.contains("Failed to load library"))
838            );
839        }
840    }
841}