1use crate::builder::PluginRegistryBuilder;
42use std::fmt;
43
44pub const LINKING_GUIDE: &str = "plugins/linking-a-dialect.html in the razeri book";
49
50pub const PLUGIN_REGISTER_SYMBOL: &str = "razeri_plugin_register_v1";
55
56pub const PLUGIN_BUILD_SYMBOL: &str = "razeri_plugin_build";
61
62pub type RegisterPluginFn = unsafe fn(&mut PluginRegistryBuilder);
69
70pub type BuildDeclarationFn = extern "C" fn() -> *const export::ExportedBuildDeclaration;
75
76#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct BuildDeclaration {
83 pub compiler: String,
86 pub plugin_api: String,
88 pub contract: String,
90 pub source: u64,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum BuildPart {
100 Compiler,
102 PluginApi,
104 Contract,
106 Source,
108}
109
110impl BuildDeclaration {
111 #[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 #[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 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#[derive(Debug, Clone, PartialEq, Eq)]
165pub enum PluginRefusal {
166 Undeclared,
170 Unreadable {
172 form: u32,
174 },
175 BuiltDifferently {
177 built_with: BuildDeclaration,
179 },
180}
181
182impl PluginRefusal {
183 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198enum OlderSide {
199 Plugin,
201 Razeri,
203}
204
205fn 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
225fn 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 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 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 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
305fn 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#[derive(Debug)]
334pub enum PluginLoadResult {
335 Loaded {
337 name: String,
339 path: std::path::PathBuf,
341 },
342 Refused {
345 path: std::path::PathBuf,
347 refusal: PluginRefusal,
349 },
350 Failed {
352 path: std::path::PathBuf,
354 error: String,
356 },
357}
358
359pub 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 let form = unsafe { record.cast::<u32>().read() };
381 if form != export::FORM {
382 return Err(unreadable(form));
383 }
384 let record = unsafe { &*record };
386 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#[doc(hidden)]
402pub mod export {
403 use std::ffi::{CStr, c_char};
404
405 pub use inventory;
406
407 pub const FORM: u32 = 1;
410
411 #[repr(C)]
416 pub struct ExportedBuildDeclaration {
417 pub form: u32,
419 pub compiler: *const c_char,
421 pub plugin_api: *const c_char,
423 pub contract: *const c_char,
425 pub source: u64,
427 }
428
429 unsafe impl Sync for ExportedBuildDeclaration {}
432
433 pub const COMPILER: &CStr = c_str(concat!(env!("RAZERI_PLUGIN_API_COMPILER"), "\0"));
436
437 pub const PLUGIN_API: &CStr = c_str(concat!(env!("CARGO_PKG_VERSION"), "\0"));
439
440 pub const CONTRACT: &CStr = c_str_from_bytes(&CONTRACT_WITH_NUL);
442
443 pub const SOURCE: u64 = fold(razeri_plugin_contract::SOURCE_FINGERPRINT, API_SOURCE);
446
447 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 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 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 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#[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 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 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 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 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 drop(lib);
590 return PluginLoadResult::Refused { path: path.to_path_buf(), refusal };
591 }
592
593 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 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
609 unsafe { register_fn(builder) };
611 }));
612
613 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 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 #[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 }
654}
655
656#[cfg(all(feature = "dynamic", not(target_arch = "wasm32")))]
657pub use loader::load_plugins_from_dir;
658
659#[cfg(test)]
662mod tests {
663 use super::export::{ExportedBuildDeclaration, FORM, THIS_BUILD};
664 use super::*;
665
666 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 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 #[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 #[test]
701 fn the_exported_declaration_reads_back_as_this_build() {
702 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 #[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 #[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 #[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 #[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 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 #[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 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 #[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 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 #[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 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 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 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}