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