1use crate::builder::PluginRegistryBuilder;
39use std::fmt;
40
41pub const LINKING_GUIDE: &str = "plugins/linking-a-dialect.html in the razeri book";
46
47pub const PLUGIN_REGISTER_SYMBOL: &str = "razeri_plugin_register_v1";
52
53pub const PLUGIN_BUILD_SYMBOL: &str = "razeri_plugin_build";
58
59pub type RegisterPluginFn = unsafe fn(&mut PluginRegistryBuilder);
66
67pub type BuildDeclarationFn = extern "C" fn() -> *const export::ExportedBuildDeclaration;
72
73#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct BuildDeclaration {
80 pub compiler: String,
83 pub plugin_api: String,
85 pub contract: String,
87 pub source: u64,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum BuildPart {
97 Compiler,
99 PluginApi,
101 Contract,
103 Source,
105}
106
107impl BuildDeclaration {
108 #[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 #[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 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#[derive(Debug, Clone, PartialEq, Eq)]
162pub enum PluginRefusal {
163 Undeclared,
167 Unreadable {
169 form: u32,
171 },
172 BuiltDifferently {
174 built_with: BuildDeclaration,
176 },
177}
178
179impl PluginRefusal {
180 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
195enum OlderSide {
196 Plugin,
198 Razeri,
200}
201
202fn 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
222fn 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 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 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 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
302fn 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#[derive(Debug)]
331pub enum PluginLoadResult {
332 Loaded {
334 name: String,
336 path: std::path::PathBuf,
338 },
339 Refused {
342 path: std::path::PathBuf,
344 refusal: PluginRefusal,
346 },
347 Failed {
349 path: std::path::PathBuf,
351 error: String,
353 },
354}
355
356pub 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 let form = unsafe { record.cast::<u32>().read() };
378 if form != export::FORM {
379 return Err(unreadable(form));
380 }
381 let record = unsafe { &*record };
383 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#[doc(hidden)]
399pub mod export {
400 use std::ffi::{CStr, c_char};
401
402 pub use inventory;
403
404 pub const FORM: u32 = 1;
407
408 #[repr(C)]
413 pub struct ExportedBuildDeclaration {
414 pub form: u32,
416 pub compiler: *const c_char,
418 pub plugin_api: *const c_char,
420 pub contract: *const c_char,
422 pub source: u64,
424 }
425
426 unsafe impl Sync for ExportedBuildDeclaration {}
429
430 pub const COMPILER: &CStr = c_str(concat!(env!("RAZERI_PLUGIN_API_COMPILER"), "\0"));
433
434 pub const PLUGIN_API: &CStr = c_str(concat!(env!("CARGO_PKG_VERSION"), "\0"));
436
437 pub const CONTRACT: &CStr = c_str_from_bytes(&CONTRACT_WITH_NUL);
439
440 pub const SOURCE: u64 = fold(razeri_plugin_contract::SOURCE_FINGERPRINT, API_SOURCE);
443
444 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 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 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 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#[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 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 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 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 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 drop(lib);
587 return PluginLoadResult::Refused { path: path.to_path_buf(), refusal };
588 }
589
590 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 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
606 unsafe { register_fn(builder) };
608 }));
609
610 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 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 #[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 }
651}
652
653#[cfg(all(feature = "dynamic", not(target_arch = "wasm32")))]
654pub use loader::load_plugins_from_dir;
655
656#[cfg(test)]
659mod tests {
660 use super::export::{ExportedBuildDeclaration, FORM, THIS_BUILD};
661 use super::*;
662
663 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 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 #[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 #[test]
698 fn the_exported_declaration_reads_back_as_this_build() {
699 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 #[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 #[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 #[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 #[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 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 #[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 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 #[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 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 #[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 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 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 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}