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`], unless the plugin was
159/// built against newer plugin crates than this razeri: then it says to run the
160/// razeri the plugin was built for, and names both builds.
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub enum PluginRefusal {
163    /// The library does not say how it was built: it has no
164    /// [`PLUGIN_BUILD_SYMBOL`]. Every plugin exported without
165    /// [`export_plugin!`](crate::export_plugin) is one of these.
166    Undeclared,
167    /// The library says how it was built in a form this razeri cannot read.
168    Unreadable {
169        /// The form number the library declared.
170        form: u32,
171    },
172    /// The library was built differently from this razeri.
173    BuiltDifferently {
174        /// How the library was built.
175        built_with: BuildDeclaration,
176    },
177}
178
179impl PluginRefusal {
180    /// The refusal a plugin declaring `built_with` receives from this razeri,
181    /// or `None` when it was built exactly as this razeri was.
182    #[must_use]
183    pub fn for_declaration(built_with: BuildDeclaration) -> Option<Self> {
184        if built_with.differences(&BuildDeclaration::this_build()).is_empty() {
185            None
186        } else {
187            Some(Self::BuiltDifferently { built_with })
188        }
189    }
190}
191
192/// Which side of a refusal is the older build: the plugin, or the razeri
193/// loading it.
194#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195enum OlderSide {
196    /// The plugin was built against older plugin crates than this razeri.
197    Plugin,
198    /// This razeri was built against older plugin crates than the plugin.
199    Razeri,
200}
201
202/// **Which side is older**, read from the two crate versions: the plugin
203/// where neither of its versions is newer than this razeri's and one is
204/// older, this razeri where the reverse holds. `None` where the versions
205/// agree, where one is newer and the other older, or where either is not a
206/// version this can read: no side is plainly the older one then.
207fn older_side(built_with: &BuildDeclaration, needs: &BuildDeclaration) -> Option<OlderSide> {
208    let pairs =
209        [(&built_with.plugin_api, &needs.plugin_api), (&built_with.contract, &needs.contract)];
210    let mut orders = Vec::new();
211    for (plugin, razeri) in pairs {
212        orders.push(release_numbers(plugin)?.cmp(&release_numbers(razeri)?));
213    }
214    let any = |order| orders.contains(&order);
215    match (any(std::cmp::Ordering::Less), any(std::cmp::Ordering::Greater)) {
216        (true, false) => Some(OlderSide::Plugin),
217        (false, true) => Some(OlderSide::Razeri),
218        _ => None,
219    }
220}
221
222/// `0.18.1` as `[0, 18, 1]`; a pre-release or build suffix (`-pre.1`,
223/// `+local`) is left off. `None` for anything that is not dotted numbers.
224fn release_numbers(version: &str) -> Option<Vec<u64>> {
225    let release = version.split(['-', '+']).next()?;
226    release.split('.').map(|part| part.parse().ok()).collect()
227}
228
229impl fmt::Display for PluginRefusal {
230    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
231        let needs = BuildDeclaration::this_build();
232        let older = match self {
233            Self::BuiltDifferently { built_with } => older_side(built_with, &needs),
234            Self::Undeclared | Self::Unreadable { .. } => None,
235        };
236
237        // **A plugin newer than this razeri is not told to rebuild**
238        // (`fr-1029-72`): rebuilding would move a current plugin back to an
239        // older contract. The cure is the razeri it was built for.
240        if let (Some(OlderSide::Razeri), Self::BuiltDifferently { built_with }) = (older, self) {
241            return write!(
242                f,
243                "Run the razeri this plugin was built for, or link it into a razeri you build \
244                 yourself ({LINKING_GUIDE}). This razeri is older than the plugin: it was built \
245                 against razeri-plugin-api {} and razeri-plugin-contract {}, and the plugin \
246                 against razeri-plugin-api {} and razeri-plugin-contract {}. A plugin loaded from \
247                 a directory must be built exactly as the razeri that loads it.",
248                needs.plugin_api, needs.contract, built_with.plugin_api, built_with.contract
249            );
250        }
251
252        // What to do, first.
253        let only_the_source_differs = matches!(
254            self,
255            Self::BuiltDifferently { built_with }
256                if built_with.differences(&needs) == [BuildPart::Source]
257        );
258        if only_the_source_differs {
259            write!(
260                f,
261                "Rebuild it with {} against the same source of razeri-plugin-api {} and \
262                 razeri-plugin-contract {} this razeri was built from (source {:016x})",
263                needs.compiler, needs.plugin_api, needs.contract, needs.source
264            )?;
265        } else {
266            write!(
267                f,
268                "Rebuild it with {} against razeri-plugin-api {} and razeri-plugin-contract {}",
269                needs.compiler, needs.plugin_api, needs.contract
270            )?;
271        }
272        if matches!(self, Self::Undeclared) {
273            f.write_str(", exporting it with razeri_plugin_api::export_plugin!")?;
274        }
275        write!(f, ", or link it into a razeri you build yourself ({LINKING_GUIDE}). ")?;
276
277        // Then why.
278        match self {
279            Self::Undeclared => f.write_str("It does not say how it was built")?,
280            Self::Unreadable { form, .. } => write!(
281                f,
282                "It says how it was built in a form this razeri does not read (form {form})"
283            )?,
284            Self::BuiltDifferently { built_with } => {
285                if older == Some(OlderSide::Plugin) {
286                    f.write_str("The plugin is older than this razeri: ")?;
287                    write!(f, "it was built {}", built_differently(built_with, &needs))?;
288                } else {
289                    write!(f, "It was built {}", built_differently(built_with, &needs))?;
290                }
291            }
292        }
293        f.write_str(
294            ", and a plugin loaded from a directory must be built exactly as the razeri that \
295             loads it.",
296        )
297    }
298}
299
300impl std::error::Error for PluginRefusal {}
301
302/// The parts of `built_with` that differ from `needs`, as the end of the
303/// sentence "It was built …".
304fn built_differently(built_with: &BuildDeclaration, needs: &BuildDeclaration) -> String {
305    let parts = built_with.differences(needs);
306    let mut clauses = Vec::new();
307    if parts.contains(&BuildPart::Compiler) {
308        clauses.push(format!("with {}", built_with.compiler));
309    }
310    let mut against = Vec::new();
311    if parts.contains(&BuildPart::PluginApi) {
312        against.push(format!("razeri-plugin-api {}", built_with.plugin_api));
313    }
314    if parts.contains(&BuildPart::Contract) {
315        against.push(format!("razeri-plugin-contract {}", built_with.contract));
316    }
317    if !against.is_empty() {
318        clauses.push(format!("against {}", against.join(" and ")));
319    }
320    if parts.contains(&BuildPart::Source) {
321        clauses.push(format!(
322            "against another source of those two versions (source {:016x})",
323            built_with.source
324        ));
325    }
326    clauses.join(" ")
327}
328
329/// Outcome of loading a single plugin library.
330#[derive(Debug)]
331pub enum PluginLoadResult {
332    /// Plugin loaded and registered successfully.
333    Loaded {
334        /// Display name (file stem).
335        name: String,
336        /// Path to the shared library.
337        path: std::path::PathBuf,
338    },
339    /// The library was found and not loaded, because it was not built as
340    /// this razeri was. None of its Rust code ran.
341    Refused {
342        /// Path to the shared library.
343        path: std::path::PathBuf,
344        /// Why it was refused.
345        refusal: PluginRefusal,
346    },
347    /// Library could not be loaded or registration failed.
348    Failed {
349        /// Path to the shared library.
350        path: std::path::PathBuf,
351        /// Error description.
352        error: String,
353    },
354}
355
356/// Read a declaration a library exported.
357///
358/// # Errors
359///
360/// A [`PluginRefusal::Unreadable`] when the record is in a form this razeri
361/// does not read, or a string in it is missing.
362///
363/// # Safety
364///
365/// `record` must be null or point to a live [`export::ExportedBuildDeclaration`]
366/// whose string pointers are null or point to NUL-terminated strings.
367pub unsafe fn read_declaration(
368    record: *const export::ExportedBuildDeclaration,
369) -> Result<BuildDeclaration, PluginRefusal> {
370    let unreadable = |form| PluginRefusal::Unreadable { form };
371    if record.is_null() {
372        return Err(unreadable(0));
373    }
374    // The form comes first and is read on its own: a record in another form
375    // may be laid out differently after it.
376    // SAFETY: the caller promises a live record, which begins with its form.
377    let form = unsafe { record.cast::<u32>().read() };
378    if form != export::FORM {
379        return Err(unreadable(form));
380    }
381    // SAFETY: a live record in the form this crate writes.
382    let record = unsafe { &*record };
383    // SAFETY: the caller promises each string pointer is null or NUL-terminated.
384    let text = |part: *const std::ffi::c_char| {
385        (!part.is_null())
386            .then(|| unsafe { std::ffi::CStr::from_ptr(part) }.to_string_lossy().into_owned())
387    };
388    match (text(record.compiler), text(record.plugin_api), text(record.contract)) {
389        (Some(compiler), Some(plugin_api), Some(contract)) => {
390            Ok(BuildDeclaration { compiler, plugin_api, contract, source: record.source })
391        }
392        _ => Err(unreadable(form)),
393    }
394}
395
396/// What [`export_plugin!`](crate::export_plugin) expands to reaches through
397/// here. Not for use by hand.
398#[doc(hidden)]
399pub mod export {
400    use std::ffi::{CStr, c_char};
401
402    pub use inventory;
403
404    /// The form of [`ExportedBuildDeclaration`] this crate writes and reads.
405    /// A record declaring any other form is refused as unreadable.
406    pub const FORM: u32 = 1;
407
408    /// A [`BuildDeclaration`](super::BuildDeclaration) as it crosses the
409    /// library boundary: C strings and one number, laid out as C lays them
410    /// out, with the form first so a reader knows how to read the rest.
411    /// A later form may add fields after these; it never moves one.
412    #[repr(C)]
413    pub struct ExportedBuildDeclaration {
414        /// Which form of this record follows.
415        pub form: u32,
416        /// The compiler, NUL-terminated.
417        pub compiler: *const c_char,
418        /// The version of `razeri-plugin-api`, NUL-terminated.
419        pub plugin_api: *const c_char,
420        /// The version of `razeri-plugin-contract`, NUL-terminated.
421        pub contract: *const c_char,
422        /// The fingerprint of both crates' source.
423        pub source: u64,
424    }
425
426    // SAFETY: every pointer in the record points to a string that lives for
427    // the life of the program and is never written.
428    unsafe impl Sync for ExportedBuildDeclaration {}
429
430    /// The compiler this crate was built with, as `rustc -V` names it. The
431    /// build script asks the compiler cargo is building with.
432    pub const COMPILER: &CStr = c_str(concat!(env!("RAZERI_PLUGIN_API_COMPILER"), "\0"));
433
434    /// This crate's version.
435    pub const PLUGIN_API: &CStr = c_str(concat!(env!("CARGO_PKG_VERSION"), "\0"));
436
437    /// The version of `razeri-plugin-contract` this crate was built against.
438    pub const CONTRACT: &CStr = c_str_from_bytes(&CONTRACT_WITH_NUL);
439
440    /// The fingerprint of both crates' source: the contract's, then this
441    /// crate's, folded into one number.
442    pub const SOURCE: u64 = fold(razeri_plugin_contract::SOURCE_FINGERPRINT, API_SOURCE);
443
444    /// This crate's own source fingerprint, from its build script.
445    const API_SOURCE: u64 = match u64::from_str_radix(env!("RAZERI_PLUGIN_API_SOURCE"), 16) {
446        Ok(fingerprint) => fingerprint,
447        Err(_) => panic!("the build script writes the fingerprint in hexadecimal"),
448    };
449
450    /// The contract's version with the NUL a C string ends in.
451    const CONTRACT_WITH_NUL: [u8; razeri_plugin_contract::VERSION.len() + 1] =
452        with_nul(razeri_plugin_contract::VERSION);
453
454    const fn with_nul<const N: usize>(text: &str) -> [u8; N] {
455        let bytes = text.as_bytes();
456        let mut out = [0; N];
457        let mut i = 0;
458        while i < bytes.len() {
459            out[i] = bytes[i];
460            i += 1;
461        }
462        out
463    }
464
465    const fn c_str(text: &'static str) -> &'static CStr {
466        c_str_from_bytes(text.as_bytes())
467    }
468
469    const fn c_str_from_bytes(bytes: &'static [u8]) -> &'static CStr {
470        match CStr::from_bytes_with_nul(bytes) {
471            Ok(text) => text,
472            Err(_) => panic!("a part of the build declaration holds a NUL"),
473        }
474    }
475
476    /// FNV-1a over the two fingerprints' bytes, the contract's first.
477    const fn fold(contract: u64, api: u64) -> u64 {
478        let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
479        let bytes = [contract.to_le_bytes(), api.to_le_bytes()];
480        let mut half = 0;
481        while half < 2 {
482            let mut i = 0;
483            while i < 8 {
484                hash ^= bytes[half][i] as u64;
485                hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
486                i += 1;
487            }
488            half += 1;
489        }
490        hash
491    }
492
493    /// This build's declaration, the record a plugin exports.
494    pub static THIS_BUILD: ExportedBuildDeclaration = ExportedBuildDeclaration {
495        form: FORM,
496        compiler: COMPILER.as_ptr(),
497        plugin_api: PLUGIN_API.as_ptr(),
498        contract: CONTRACT.as_ptr(),
499        source: SOURCE,
500    };
501}
502
503// ── Feature-gated loading implementation ────────────────────────────
504
505// Dynamic library loading (`libloading`/dlopen) is native-only: there is no
506// shared-library loading on wasm32-unknown-unknown, and `libloading` is not a
507// dependency of the wasm build. The `dynamic` feature is a no-op on wasm.
508#[cfg(all(feature = "dynamic", not(target_arch = "wasm32")))]
509mod loader {
510    use super::{
511        BuildDeclarationFn, PLUGIN_BUILD_SYMBOL, PLUGIN_REGISTER_SYMBOL, PluginLoadResult,
512        PluginRefusal, PluginRegistryBuilder, RegisterPluginFn, read_declaration,
513    };
514    use std::path::Path;
515
516    /// Load all plugin shared libraries from `dir` and register them
517    /// with `builder`.
518    ///
519    /// Scans `dir` for files with the platform-appropriate extension
520    /// (`.so`, `.dylib`, `.dll`). Each library is loaded, its build
521    /// declaration is compared with this razeri's, and its registration
522    /// function is called only when the two match.
523    ///
524    /// Returns one [`PluginLoadResult`] per library file found.
525    pub fn load_plugins_from_dir(
526        dir: &Path,
527        builder: &mut PluginRegistryBuilder,
528    ) -> Vec<PluginLoadResult> {
529        warn_if_world_writable(dir);
530
531        let entries = match std::fs::read_dir(dir) {
532            Ok(e) => e,
533            Err(e) => {
534                tracing::debug!("Cannot read plugin directory {}: {e}", dir.display());
535                return Vec::new();
536            }
537        };
538
539        let mut results = Vec::new();
540
541        for entry in entries.flatten() {
542            let path = entry.path();
543            if !is_shared_library(&path) {
544                continue;
545            }
546            results.push(load_single_plugin(&path, builder));
547        }
548
549        results
550    }
551
552    /// Load a single shared library plugin.
553    fn load_single_plugin(path: &Path, builder: &mut PluginRegistryBuilder) -> PluginLoadResult {
554        let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown").to_string();
555
556        // SAFETY: opening a library runs its initialisers, which is the one
557        // thing done before its declaration is read. A file in a plugin
558        // directory is trusted that far and no further.
559        let lib = match unsafe { libloading::Library::new(path) } {
560            Ok(l) => l,
561            Err(e) => {
562                return PluginLoadResult::Failed {
563                    path: path.to_path_buf(),
564                    error: format!("Failed to load library: {e}"),
565                };
566            }
567        };
568
569        // How the library was built, read through its C door. Nothing else
570        // in it is called until this matches this razeri's own build.
571        // SAFETY: the symbol, when present, has the C signature every
572        // compiler agrees on, and returns a record `read_declaration` reads
573        // through its C layout.
574        let declared = unsafe {
575            match lib.get::<BuildDeclarationFn>(PLUGIN_BUILD_SYMBOL.as_bytes()) {
576                Ok(declare) => read_declaration(declare()),
577                Err(_) => Err(PluginRefusal::Undeclared),
578            }
579        };
580        let refusal = match declared {
581            Ok(built_with) => PluginRefusal::for_declaration(built_with),
582            Err(refusal) => Some(refusal),
583        };
584        if let Some(refusal) = refusal {
585            // Nothing the library holds was handed to razeri, so it can go.
586            drop(lib);
587            return PluginLoadResult::Refused { path: path.to_path_buf(), refusal };
588        }
589
590        // SAFETY: the library was built exactly as this razeri was, so the
591        // registration's Rust signature means the same thing on both sides.
592        let register_fn = unsafe {
593            match lib.get::<RegisterPluginFn>(PLUGIN_REGISTER_SYMBOL.as_bytes()) {
594                Ok(f) => *f,
595                Err(e) => {
596                    return PluginLoadResult::Failed {
597                        path: path.to_path_buf(),
598                        error: format!("Missing symbol '{PLUGIN_REGISTER_SYMBOL}': {e}"),
599                    };
600                }
601            }
602        };
603
604        // Call registration wrapped in catch_unwind for safety.
605        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
606            // SAFETY: the builder outlives the call, and the build matched.
607            unsafe { register_fn(builder) };
608        }));
609
610        // Once the registration has run, the library stays loaded for the
611        // life of the process, whatever it did: anything it registered before
612        // a panic points into it, and unloading it would leave those behind.
613        std::mem::forget(lib);
614        match result {
615            Ok(()) => {
616                tracing::info!("Loaded plugin '{name}' from {}", path.display());
617                PluginLoadResult::Loaded { name, path: path.to_path_buf() }
618            }
619            Err(_) => PluginLoadResult::Failed {
620                path: path.to_path_buf(),
621                error: "Plugin registration panicked".to_string(),
622            },
623        }
624    }
625
626    /// Check if a path has a shared-library extension.
627    fn is_shared_library(path: &Path) -> bool {
628        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
629        matches!(ext, "so" | "dylib" | "dll")
630    }
631
632    /// Emit a warning if the directory is world-writable (Unix only).
633    #[cfg(unix)]
634    fn warn_if_world_writable(dir: &Path) {
635        use std::os::unix::fs::PermissionsExt;
636        if let Ok(meta) = std::fs::metadata(dir) {
637            let mode = meta.permissions().mode();
638            if mode & 0o002 != 0 {
639                tracing::warn!(
640                    "Plugin directory {} is world-writable — this is a security risk",
641                    dir.display()
642                );
643            }
644        }
645    }
646
647    #[cfg(not(unix))]
648    fn warn_if_world_writable(_dir: &Path) {
649        // No-op on non-Unix platforms.
650    }
651}
652
653#[cfg(all(feature = "dynamic", not(target_arch = "wasm32")))]
654pub use loader::load_plugins_from_dir;
655
656// ── Tests ────────────────────────────────────────────────────────────
657
658#[cfg(test)]
659mod tests {
660    use super::export::{ExportedBuildDeclaration, FORM, THIS_BUILD};
661    use super::*;
662
663    /// A declaration that differs from this build only where the test says.
664    fn this_build_but(change: impl FnOnce(&mut BuildDeclaration)) -> BuildDeclaration {
665        let mut declared = BuildDeclaration::this_build();
666        change(&mut declared);
667        declared
668    }
669
670    /// The message a plugin declaring `declared` is refused with.
671    fn refusal_text(declared: BuildDeclaration) -> String {
672        PluginRefusal::for_declaration(declared)
673            .expect("a declaration that differs is refused")
674            .to_string()
675    }
676
677    #[test]
678    fn symbol_names_are_stable() {
679        assert_eq!(PLUGIN_REGISTER_SYMBOL, "razeri_plugin_register_v1");
680        assert_eq!(PLUGIN_BUILD_SYMBOL, "razeri_plugin_build");
681    }
682
683    /// **This build names its compiler, both crates and their source.** A
684    /// declaration with an empty part would match any other with the same
685    /// empty part, which is the silence the declaration exists to end.
686    #[test]
687    fn this_build_names_its_compiler_both_crate_versions_and_its_source() {
688        let this = BuildDeclaration::this_build();
689        assert!(this.compiler.starts_with("rustc "), "{this:?}");
690        assert_eq!(this.plugin_api, env!("CARGO_PKG_VERSION"));
691        assert_eq!(this.contract, razeri_plugin_contract::VERSION);
692        assert_ne!(this.source, 0, "{this:?}");
693    }
694
695    /// **What a plugin exports reads back, through its C layout, as the build
696    /// that made it.**
697    #[test]
698    fn the_exported_declaration_reads_back_as_this_build() {
699        // SAFETY: THIS_BUILD is a live record of static C strings.
700        let read = unsafe { read_declaration(&raw const THIS_BUILD) };
701        assert_eq!(read, Ok(BuildDeclaration::this_build()));
702    }
703
704    #[test]
705    fn a_declaration_identical_to_this_build_is_accepted() {
706        assert_eq!(PluginRefusal::for_declaration(BuildDeclaration::this_build()), None);
707        let this = BuildDeclaration::this_build();
708        assert!(this.differences(&this).is_empty());
709    }
710
711    /// **Another compiler is refused, and the refusal names both.** A newer
712    /// compiler over the same source is enough to crash a process that calls
713    /// into the library, so the compiler is compared on its own.
714    #[test]
715    fn a_different_compiler_is_refused_naming_the_compiler() {
716        let needs = BuildDeclaration::this_build();
717        let declared = this_build_but(|d| d.compiler = "rustc 1.0.0 (elsewhere)".to_string());
718        assert_eq!(declared.differences(&needs), vec![BuildPart::Compiler]);
719
720        let text = refusal_text(declared);
721        assert!(text.contains("rustc 1.0.0 (elsewhere)"), "{text}");
722        assert!(text.contains(&needs.compiler), "{text}");
723    }
724
725    #[test]
726    fn a_different_plugin_api_version_is_refused_naming_it() {
727        let needs = BuildDeclaration::this_build();
728        let declared = this_build_but(|d| d.plugin_api = "0.0.1".to_string());
729        assert_eq!(declared.differences(&needs), vec![BuildPart::PluginApi]);
730
731        let text = refusal_text(declared);
732        assert!(text.contains("razeri-plugin-api 0.0.1"), "{text}");
733        assert!(text.contains(&format!("razeri-plugin-api {}", needs.plugin_api)), "{text}");
734    }
735
736    #[test]
737    fn a_different_contract_version_is_refused_naming_the_contract() {
738        let needs = BuildDeclaration::this_build();
739        let declared = this_build_but(|d| d.contract = "0.0.2".to_string());
740        assert_eq!(declared.differences(&needs), vec![BuildPart::Contract]);
741
742        let text = refusal_text(declared);
743        assert!(text.contains("razeri-plugin-contract 0.0.2"), "{text}");
744        assert!(text.contains(&format!("razeri-plugin-contract {}", needs.contract)), "{text}");
745    }
746
747    /// **The same version numbers over different source are refused.** A
748    /// crate's version moves when it is published, not when its source does,
749    /// so a plugin built against the published crates and a razeri built from
750    /// newer source can carry the same numbers and different types.
751    #[test]
752    fn the_same_versions_over_different_source_are_refused() {
753        let needs = BuildDeclaration::this_build();
754        let declared = this_build_but(|d| d.source = needs.source ^ 1);
755        assert_eq!(declared.differences(&needs), vec![BuildPart::Source]);
756
757        let text = refusal_text(declared);
758        assert!(text.contains(&format!("{:016x}", needs.source)), "{text}");
759        assert!(text.contains(&format!("{:016x}", needs.source ^ 1)), "{text}");
760    }
761
762    /// Every part that differs is named, in the order a reader meets them.
763    #[test]
764    fn every_part_that_differs_is_named() {
765        let needs = BuildDeclaration::this_build();
766        let declared = BuildDeclaration {
767            compiler: "rustc 1.0.0 (elsewhere)".to_string(),
768            plugin_api: "0.0.1".to_string(),
769            contract: "0.0.2".to_string(),
770            source: needs.source ^ 1,
771        };
772        assert_eq!(
773            declared.differences(&needs),
774            vec![BuildPart::Compiler, BuildPart::PluginApi, BuildPart::Contract]
775        );
776    }
777
778    /// **The fix comes first.** Every refusal opens with what to do, names
779    /// both ways to do it, and never names a type or a symbol the reader did
780    /// not write.
781    #[test]
782    fn every_refusal_says_what_to_do_first_and_names_both_ways() {
783        let needs = BuildDeclaration::this_build();
784        let refusals = [
785            PluginRefusal::Undeclared,
786            PluginRefusal::Unreadable { form: FORM + 1 },
787            PluginRefusal::for_declaration(this_build_but(|d| d.compiler = "rustc 1.0.0".into()))
788                .expect("refused"),
789        ];
790        for refusal in refusals {
791            let text = refusal.to_string();
792            assert!(text.starts_with("Rebuild it "), "the fix is not first: {text}");
793            assert!(text.contains(&needs.compiler), "{text}");
794            assert!(text.contains(&format!("razeri-plugin-api {}", needs.plugin_api)), "{text}");
795            assert!(text.contains(&format!("razeri-plugin-contract {}", needs.contract)), "{text}");
796            assert!(text.contains("link it into a razeri you build"), "{text}");
797            assert!(text.contains(LINKING_GUIDE), "{text}");
798            for internal in ["BuildDeclaration", "PluginRefusal", "razeri_plugin_build"] {
799                assert!(!text.contains(internal), "names {internal}: {text}");
800            }
801        }
802    }
803
804    /// `0.18.2` moved `steps` minor versions: `1` is `0.19.0`, `-1` is
805    /// `0.17.0`.
806    fn minor_moved(version: &str, steps: i64) -> String {
807        let mut parts = version.split('.').map(|part| part.parse::<i64>().expect("a number"));
808        let (major, minor) = (parts.next().expect("major"), parts.next().expect("minor"));
809        format!("{major}.{}.0", minor + steps)
810    }
811
812    /// **A plugin newer than this razeri is told to run the razeri it was
813    /// built for**, not to rebuild against an older contract (`fr-1029-72`).
814    /// Measured on `pre.11`: a template built against contract 0.17.1, loaded
815    /// by a `pre.10` razeri, was told to rebuild against 0.16.0 — a downgrade
816    /// of a current plugin to an old contract.
817    #[test]
818    fn a_plugin_newer_than_this_razeri_is_told_to_run_the_razeri_it_was_built_for() {
819        let needs = BuildDeclaration::this_build();
820        let declared = this_build_but(|d| {
821            d.plugin_api = minor_moved(&needs.plugin_api, 1);
822            d.contract = minor_moved(&needs.contract, 1);
823        });
824        let text = refusal_text(declared.clone());
825        assert!(text.starts_with("Run the razeri this plugin was built for"), "{text}");
826        assert!(text.contains("This razeri is older than the plugin"), "{text}");
827        assert!(!text.contains("Rebuild it"), "no downgrade is offered: {text}");
828        for version in
829            [&needs.plugin_api, &needs.contract, &declared.plugin_api, &declared.contract]
830        {
831            assert!(text.contains(version.as_str()), "names {version}: {text}");
832        }
833        assert!(text.contains(LINKING_GUIDE), "{text}");
834
835        // Newer on one part and the same on the other is still newer.
836        let text = refusal_text(this_build_but(|d| d.contract = minor_moved(&needs.contract, 1)));
837        assert!(text.starts_with("Run the razeri this plugin was built for"), "{text}");
838    }
839
840    /// **A plugin older than this razeri is told to rebuild**, and told it is
841    /// the older side.
842    #[test]
843    fn a_plugin_older_than_this_razeri_is_told_to_rebuild_and_that_it_is_older() {
844        let needs = BuildDeclaration::this_build();
845        let declared = this_build_but(|d| {
846            d.plugin_api = minor_moved(&needs.plugin_api, -1);
847            d.contract = minor_moved(&needs.contract, -1);
848        });
849        let text = refusal_text(declared);
850        assert!(text.starts_with("Rebuild it with"), "{text}");
851        assert!(text.contains("The plugin is older than this razeri"), "{text}");
852
853        // One part newer and one older says neither is older, and keeps the
854        // rebuild: there is no razeri the plugin was built for to point at.
855        let mixed = refusal_text(this_build_but(|d| {
856            d.plugin_api = minor_moved(&needs.plugin_api, 1);
857            d.contract = minor_moved(&needs.contract, -1);
858        }));
859        assert!(mixed.starts_with("Rebuild it with"), "{mixed}");
860        assert!(!mixed.contains("older than"), "{mixed}");
861    }
862
863    #[test]
864    fn a_plugin_that_declares_nothing_is_told_so_and_how_to_export_one() {
865        let text = PluginRefusal::Undeclared.to_string();
866        assert!(text.contains("does not say how it was built"), "{text}");
867        assert!(text.contains("export_plugin!"), "{text}");
868    }
869
870    /// **A form this razeri does not read is refused, not guessed at.**
871    #[test]
872    fn a_declaration_in_a_form_this_razeri_does_not_read_is_refused() {
873        let later = ExportedBuildDeclaration {
874            form: FORM + 1,
875            compiler: std::ptr::null(),
876            plugin_api: std::ptr::null(),
877            contract: std::ptr::null(),
878            source: 0,
879        };
880        // SAFETY: a live record; its null strings are never read for an
881        // unknown form.
882        let read = unsafe { read_declaration(&raw const later) };
883        assert_eq!(read, Err(PluginRefusal::Unreadable { form: FORM + 1 }));
884        let text = read.unwrap_err().to_string();
885        assert!(text.contains(&format!("form {}", FORM + 1)), "{text}");
886    }
887
888    #[test]
889    fn a_record_with_a_missing_part_is_refused_as_unreadable() {
890        let broken = ExportedBuildDeclaration {
891            form: FORM,
892            compiler: std::ptr::null(),
893            plugin_api: c"0.0.1".as_ptr(),
894            contract: c"0.0.2".as_ptr(),
895            source: 1,
896        };
897        // SAFETY: a live record whose strings are null or static.
898        let read = unsafe { read_declaration(&raw const broken) };
899        assert!(matches!(read, Err(PluginRefusal::Unreadable { form: FORM, .. })), "{read:?}");
900    }
901
902    #[test]
903    fn no_record_at_all_is_refused_as_unreadable() {
904        // SAFETY: null is allowed and never dereferenced.
905        let read = unsafe { read_declaration(std::ptr::null()) };
906        assert!(matches!(read, Err(PluginRefusal::Unreadable { .. })), "{read:?}");
907    }
908
909    #[cfg(all(feature = "dynamic", not(target_arch = "wasm32")))]
910    mod dynamic_tests {
911        use super::*;
912        use std::path::Path;
913
914        #[test]
915        fn load_from_nonexistent_dir_returns_empty() {
916            let mut builder = PluginRegistryBuilder::new();
917            let results = load_plugins_from_dir(
918                Path::new("/tmp/razeri-nonexistent-plugin-dir-12345"),
919                &mut builder,
920            );
921            assert!(results.is_empty());
922        }
923
924        #[test]
925        fn load_from_empty_dir_returns_empty() {
926            let dir = tempfile::tempdir().unwrap();
927            let mut builder = PluginRegistryBuilder::new();
928            let results = load_plugins_from_dir(dir.path(), &mut builder);
929            assert!(results.is_empty());
930        }
931
932        #[test]
933        fn load_skips_non_library_files() {
934            let dir = tempfile::tempdir().unwrap();
935            std::fs::write(dir.path().join("notes.txt"), "hello").unwrap();
936            std::fs::write(dir.path().join("data.json"), "{}").unwrap();
937
938            let mut builder = PluginRegistryBuilder::new();
939            let results = load_plugins_from_dir(dir.path(), &mut builder);
940            assert!(results.is_empty());
941        }
942
943        #[test]
944        fn load_invalid_so_returns_failed() {
945            let dir = tempfile::tempdir().unwrap();
946            let lib_name = if cfg!(target_os = "macos") {
947                "bad.dylib"
948            } else if cfg!(target_os = "windows") {
949                "bad.dll"
950            } else {
951                "bad.so"
952            };
953            std::fs::write(dir.path().join(lib_name), "not a real library").unwrap();
954
955            let mut builder = PluginRegistryBuilder::new();
956            let results = load_plugins_from_dir(dir.path(), &mut builder);
957            assert_eq!(results.len(), 1);
958            assert!(
959                matches!(&results[0], PluginLoadResult::Failed { error, .. } if error.contains("Failed to load library"))
960            );
961        }
962    }
963}