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)]
160pub enum PluginRefusal {
161 Undeclared,
165 Unreadable {
167 form: u32,
169 },
170 BuiltDifferently {
172 built_with: BuildDeclaration,
174 },
175}
176
177impl PluginRefusal {
178 #[must_use]
181 pub fn for_declaration(built_with: BuildDeclaration) -> Option<Self> {
182 if built_with.differences(&BuildDeclaration::this_build()).is_empty() {
183 None
184 } else {
185 Some(Self::BuiltDifferently { built_with })
186 }
187 }
188}
189
190impl fmt::Display for PluginRefusal {
191 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
192 let needs = BuildDeclaration::this_build();
193
194 let only_the_source_differs = matches!(
196 self,
197 Self::BuiltDifferently { built_with }
198 if built_with.differences(&needs) == [BuildPart::Source]
199 );
200 if only_the_source_differs {
201 write!(
202 f,
203 "Rebuild it with {} against the same source of razeri-plugin-api {} and \
204 razeri-plugin-contract {} this razeri was built from (source {:016x})",
205 needs.compiler, needs.plugin_api, needs.contract, needs.source
206 )?;
207 } else {
208 write!(
209 f,
210 "Rebuild it with {} against razeri-plugin-api {} and razeri-plugin-contract {}",
211 needs.compiler, needs.plugin_api, needs.contract
212 )?;
213 }
214 if matches!(self, Self::Undeclared) {
215 f.write_str(", exporting it with razeri_plugin_api::export_plugin!")?;
216 }
217 write!(f, ", or link it into a razeri you build yourself ({LINKING_GUIDE}). ")?;
218
219 match self {
221 Self::Undeclared => f.write_str("It does not say how it was built")?,
222 Self::Unreadable { form, .. } => write!(
223 f,
224 "It says how it was built in a form this razeri does not read (form {form})"
225 )?,
226 Self::BuiltDifferently { built_with } => {
227 write!(f, "It was built {}", built_differently(built_with, &needs))?;
228 }
229 }
230 f.write_str(
231 ", and a plugin loaded from a directory must be built exactly as the razeri that \
232 loads it.",
233 )
234 }
235}
236
237impl std::error::Error for PluginRefusal {}
238
239fn built_differently(built_with: &BuildDeclaration, needs: &BuildDeclaration) -> String {
242 let parts = built_with.differences(needs);
243 let mut clauses = Vec::new();
244 if parts.contains(&BuildPart::Compiler) {
245 clauses.push(format!("with {}", built_with.compiler));
246 }
247 let mut against = Vec::new();
248 if parts.contains(&BuildPart::PluginApi) {
249 against.push(format!("razeri-plugin-api {}", built_with.plugin_api));
250 }
251 if parts.contains(&BuildPart::Contract) {
252 against.push(format!("razeri-plugin-contract {}", built_with.contract));
253 }
254 if !against.is_empty() {
255 clauses.push(format!("against {}", against.join(" and ")));
256 }
257 if parts.contains(&BuildPart::Source) {
258 clauses.push(format!(
259 "against another source of those two versions (source {:016x})",
260 built_with.source
261 ));
262 }
263 clauses.join(" ")
264}
265
266#[derive(Debug)]
268pub enum PluginLoadResult {
269 Loaded {
271 name: String,
273 path: std::path::PathBuf,
275 },
276 Refused {
279 path: std::path::PathBuf,
281 refusal: PluginRefusal,
283 },
284 Failed {
286 path: std::path::PathBuf,
288 error: String,
290 },
291}
292
293pub unsafe fn read_declaration(
305 record: *const export::ExportedBuildDeclaration,
306) -> Result<BuildDeclaration, PluginRefusal> {
307 let unreadable = |form| PluginRefusal::Unreadable { form };
308 if record.is_null() {
309 return Err(unreadable(0));
310 }
311 let form = unsafe { record.cast::<u32>().read() };
315 if form != export::FORM {
316 return Err(unreadable(form));
317 }
318 let record = unsafe { &*record };
320 let text = |part: *const std::ffi::c_char| {
322 (!part.is_null())
323 .then(|| unsafe { std::ffi::CStr::from_ptr(part) }.to_string_lossy().into_owned())
324 };
325 match (text(record.compiler), text(record.plugin_api), text(record.contract)) {
326 (Some(compiler), Some(plugin_api), Some(contract)) => {
327 Ok(BuildDeclaration { compiler, plugin_api, contract, source: record.source })
328 }
329 _ => Err(unreadable(form)),
330 }
331}
332
333#[doc(hidden)]
336pub mod export {
337 use std::ffi::{CStr, c_char};
338
339 pub use inventory;
340
341 pub const FORM: u32 = 1;
344
345 #[repr(C)]
350 pub struct ExportedBuildDeclaration {
351 pub form: u32,
353 pub compiler: *const c_char,
355 pub plugin_api: *const c_char,
357 pub contract: *const c_char,
359 pub source: u64,
361 }
362
363 unsafe impl Sync for ExportedBuildDeclaration {}
366
367 pub const COMPILER: &CStr = c_str(concat!(env!("RAZERI_PLUGIN_API_COMPILER"), "\0"));
370
371 pub const PLUGIN_API: &CStr = c_str(concat!(env!("CARGO_PKG_VERSION"), "\0"));
373
374 pub const CONTRACT: &CStr = c_str_from_bytes(&CONTRACT_WITH_NUL);
376
377 pub const SOURCE: u64 = fold(razeri_plugin_contract::SOURCE_FINGERPRINT, API_SOURCE);
380
381 const API_SOURCE: u64 = match u64::from_str_radix(env!("RAZERI_PLUGIN_API_SOURCE"), 16) {
383 Ok(fingerprint) => fingerprint,
384 Err(_) => panic!("the build script writes the fingerprint in hexadecimal"),
385 };
386
387 const CONTRACT_WITH_NUL: [u8; razeri_plugin_contract::VERSION.len() + 1] =
389 with_nul(razeri_plugin_contract::VERSION);
390
391 const fn with_nul<const N: usize>(text: &str) -> [u8; N] {
392 let bytes = text.as_bytes();
393 let mut out = [0; N];
394 let mut i = 0;
395 while i < bytes.len() {
396 out[i] = bytes[i];
397 i += 1;
398 }
399 out
400 }
401
402 const fn c_str(text: &'static str) -> &'static CStr {
403 c_str_from_bytes(text.as_bytes())
404 }
405
406 const fn c_str_from_bytes(bytes: &'static [u8]) -> &'static CStr {
407 match CStr::from_bytes_with_nul(bytes) {
408 Ok(text) => text,
409 Err(_) => panic!("a part of the build declaration holds a NUL"),
410 }
411 }
412
413 const fn fold(contract: u64, api: u64) -> u64 {
415 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
416 let bytes = [contract.to_le_bytes(), api.to_le_bytes()];
417 let mut half = 0;
418 while half < 2 {
419 let mut i = 0;
420 while i < 8 {
421 hash ^= bytes[half][i] as u64;
422 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
423 i += 1;
424 }
425 half += 1;
426 }
427 hash
428 }
429
430 pub static THIS_BUILD: ExportedBuildDeclaration = ExportedBuildDeclaration {
432 form: FORM,
433 compiler: COMPILER.as_ptr(),
434 plugin_api: PLUGIN_API.as_ptr(),
435 contract: CONTRACT.as_ptr(),
436 source: SOURCE,
437 };
438}
439
440#[cfg(all(feature = "dynamic", not(target_arch = "wasm32")))]
446mod loader {
447 use super::{
448 BuildDeclarationFn, PLUGIN_BUILD_SYMBOL, PLUGIN_REGISTER_SYMBOL, PluginLoadResult,
449 PluginRefusal, PluginRegistryBuilder, RegisterPluginFn, read_declaration,
450 };
451 use std::path::Path;
452
453 pub fn load_plugins_from_dir(
463 dir: &Path,
464 builder: &mut PluginRegistryBuilder,
465 ) -> Vec<PluginLoadResult> {
466 warn_if_world_writable(dir);
467
468 let entries = match std::fs::read_dir(dir) {
469 Ok(e) => e,
470 Err(e) => {
471 tracing::debug!("Cannot read plugin directory {}: {e}", dir.display());
472 return Vec::new();
473 }
474 };
475
476 let mut results = Vec::new();
477
478 for entry in entries.flatten() {
479 let path = entry.path();
480 if !is_shared_library(&path) {
481 continue;
482 }
483 results.push(load_single_plugin(&path, builder));
484 }
485
486 results
487 }
488
489 fn load_single_plugin(path: &Path, builder: &mut PluginRegistryBuilder) -> PluginLoadResult {
491 let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown").to_string();
492
493 let lib = match unsafe { libloading::Library::new(path) } {
497 Ok(l) => l,
498 Err(e) => {
499 return PluginLoadResult::Failed {
500 path: path.to_path_buf(),
501 error: format!("Failed to load library: {e}"),
502 };
503 }
504 };
505
506 let declared = unsafe {
512 match lib.get::<BuildDeclarationFn>(PLUGIN_BUILD_SYMBOL.as_bytes()) {
513 Ok(declare) => read_declaration(declare()),
514 Err(_) => Err(PluginRefusal::Undeclared),
515 }
516 };
517 let refusal = match declared {
518 Ok(built_with) => PluginRefusal::for_declaration(built_with),
519 Err(refusal) => Some(refusal),
520 };
521 if let Some(refusal) = refusal {
522 drop(lib);
524 return PluginLoadResult::Refused { path: path.to_path_buf(), refusal };
525 }
526
527 let register_fn = unsafe {
530 match lib.get::<RegisterPluginFn>(PLUGIN_REGISTER_SYMBOL.as_bytes()) {
531 Ok(f) => *f,
532 Err(e) => {
533 return PluginLoadResult::Failed {
534 path: path.to_path_buf(),
535 error: format!("Missing symbol '{PLUGIN_REGISTER_SYMBOL}': {e}"),
536 };
537 }
538 }
539 };
540
541 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
543 unsafe { register_fn(builder) };
545 }));
546
547 std::mem::forget(lib);
551 match result {
552 Ok(()) => {
553 tracing::info!("Loaded plugin '{name}' from {}", path.display());
554 PluginLoadResult::Loaded { name, path: path.to_path_buf() }
555 }
556 Err(_) => PluginLoadResult::Failed {
557 path: path.to_path_buf(),
558 error: "Plugin registration panicked".to_string(),
559 },
560 }
561 }
562
563 fn is_shared_library(path: &Path) -> bool {
565 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
566 matches!(ext, "so" | "dylib" | "dll")
567 }
568
569 #[cfg(unix)]
571 fn warn_if_world_writable(dir: &Path) {
572 use std::os::unix::fs::PermissionsExt;
573 if let Ok(meta) = std::fs::metadata(dir) {
574 let mode = meta.permissions().mode();
575 if mode & 0o002 != 0 {
576 tracing::warn!(
577 "Plugin directory {} is world-writable — this is a security risk",
578 dir.display()
579 );
580 }
581 }
582 }
583
584 #[cfg(not(unix))]
585 fn warn_if_world_writable(_dir: &Path) {
586 }
588}
589
590#[cfg(all(feature = "dynamic", not(target_arch = "wasm32")))]
591pub use loader::load_plugins_from_dir;
592
593#[cfg(test)]
596mod tests {
597 use super::export::{ExportedBuildDeclaration, FORM, THIS_BUILD};
598 use super::*;
599
600 fn this_build_but(change: impl FnOnce(&mut BuildDeclaration)) -> BuildDeclaration {
602 let mut declared = BuildDeclaration::this_build();
603 change(&mut declared);
604 declared
605 }
606
607 fn refusal_text(declared: BuildDeclaration) -> String {
609 PluginRefusal::for_declaration(declared)
610 .expect("a declaration that differs is refused")
611 .to_string()
612 }
613
614 #[test]
615 fn symbol_names_are_stable() {
616 assert_eq!(PLUGIN_REGISTER_SYMBOL, "razeri_plugin_register_v1");
617 assert_eq!(PLUGIN_BUILD_SYMBOL, "razeri_plugin_build");
618 }
619
620 #[test]
624 fn this_build_names_its_compiler_both_crate_versions_and_its_source() {
625 let this = BuildDeclaration::this_build();
626 assert!(this.compiler.starts_with("rustc "), "{this:?}");
627 assert_eq!(this.plugin_api, env!("CARGO_PKG_VERSION"));
628 assert_eq!(this.contract, razeri_plugin_contract::VERSION);
629 assert_ne!(this.source, 0, "{this:?}");
630 }
631
632 #[test]
635 fn the_exported_declaration_reads_back_as_this_build() {
636 let read = unsafe { read_declaration(&raw const THIS_BUILD) };
638 assert_eq!(read, Ok(BuildDeclaration::this_build()));
639 }
640
641 #[test]
642 fn a_declaration_identical_to_this_build_is_accepted() {
643 assert_eq!(PluginRefusal::for_declaration(BuildDeclaration::this_build()), None);
644 let this = BuildDeclaration::this_build();
645 assert!(this.differences(&this).is_empty());
646 }
647
648 #[test]
652 fn a_different_compiler_is_refused_naming_the_compiler() {
653 let needs = BuildDeclaration::this_build();
654 let declared = this_build_but(|d| d.compiler = "rustc 1.0.0 (elsewhere)".to_string());
655 assert_eq!(declared.differences(&needs), vec![BuildPart::Compiler]);
656
657 let text = refusal_text(declared);
658 assert!(text.contains("rustc 1.0.0 (elsewhere)"), "{text}");
659 assert!(text.contains(&needs.compiler), "{text}");
660 }
661
662 #[test]
663 fn a_different_plugin_api_version_is_refused_naming_it() {
664 let needs = BuildDeclaration::this_build();
665 let declared = this_build_but(|d| d.plugin_api = "0.0.1".to_string());
666 assert_eq!(declared.differences(&needs), vec![BuildPart::PluginApi]);
667
668 let text = refusal_text(declared);
669 assert!(text.contains("razeri-plugin-api 0.0.1"), "{text}");
670 assert!(text.contains(&format!("razeri-plugin-api {}", needs.plugin_api)), "{text}");
671 }
672
673 #[test]
674 fn a_different_contract_version_is_refused_naming_the_contract() {
675 let needs = BuildDeclaration::this_build();
676 let declared = this_build_but(|d| d.contract = "0.0.2".to_string());
677 assert_eq!(declared.differences(&needs), vec![BuildPart::Contract]);
678
679 let text = refusal_text(declared);
680 assert!(text.contains("razeri-plugin-contract 0.0.2"), "{text}");
681 assert!(text.contains(&format!("razeri-plugin-contract {}", needs.contract)), "{text}");
682 }
683
684 #[test]
689 fn the_same_versions_over_different_source_are_refused() {
690 let needs = BuildDeclaration::this_build();
691 let declared = this_build_but(|d| d.source = needs.source ^ 1);
692 assert_eq!(declared.differences(&needs), vec![BuildPart::Source]);
693
694 let text = refusal_text(declared);
695 assert!(text.contains(&format!("{:016x}", needs.source)), "{text}");
696 assert!(text.contains(&format!("{:016x}", needs.source ^ 1)), "{text}");
697 }
698
699 #[test]
701 fn every_part_that_differs_is_named() {
702 let needs = BuildDeclaration::this_build();
703 let declared = BuildDeclaration {
704 compiler: "rustc 1.0.0 (elsewhere)".to_string(),
705 plugin_api: "0.0.1".to_string(),
706 contract: "0.0.2".to_string(),
707 source: needs.source ^ 1,
708 };
709 assert_eq!(
710 declared.differences(&needs),
711 vec![BuildPart::Compiler, BuildPart::PluginApi, BuildPart::Contract]
712 );
713 }
714
715 #[test]
719 fn every_refusal_says_what_to_do_first_and_names_both_ways() {
720 let needs = BuildDeclaration::this_build();
721 let refusals = [
722 PluginRefusal::Undeclared,
723 PluginRefusal::Unreadable { form: FORM + 1 },
724 PluginRefusal::for_declaration(this_build_but(|d| d.compiler = "rustc 1.0.0".into()))
725 .expect("refused"),
726 ];
727 for refusal in refusals {
728 let text = refusal.to_string();
729 assert!(text.starts_with("Rebuild it "), "the fix is not first: {text}");
730 assert!(text.contains(&needs.compiler), "{text}");
731 assert!(text.contains(&format!("razeri-plugin-api {}", needs.plugin_api)), "{text}");
732 assert!(text.contains(&format!("razeri-plugin-contract {}", needs.contract)), "{text}");
733 assert!(text.contains("link it into a razeri you build"), "{text}");
734 assert!(text.contains(LINKING_GUIDE), "{text}");
735 for internal in ["BuildDeclaration", "PluginRefusal", "razeri_plugin_build"] {
736 assert!(!text.contains(internal), "names {internal}: {text}");
737 }
738 }
739 }
740
741 #[test]
742 fn a_plugin_that_declares_nothing_is_told_so_and_how_to_export_one() {
743 let text = PluginRefusal::Undeclared.to_string();
744 assert!(text.contains("does not say how it was built"), "{text}");
745 assert!(text.contains("export_plugin!"), "{text}");
746 }
747
748 #[test]
750 fn a_declaration_in_a_form_this_razeri_does_not_read_is_refused() {
751 let later = ExportedBuildDeclaration {
752 form: FORM + 1,
753 compiler: std::ptr::null(),
754 plugin_api: std::ptr::null(),
755 contract: std::ptr::null(),
756 source: 0,
757 };
758 let read = unsafe { read_declaration(&raw const later) };
761 assert_eq!(read, Err(PluginRefusal::Unreadable { form: FORM + 1 }));
762 let text = read.unwrap_err().to_string();
763 assert!(text.contains(&format!("form {}", FORM + 1)), "{text}");
764 }
765
766 #[test]
767 fn a_record_with_a_missing_part_is_refused_as_unreadable() {
768 let broken = ExportedBuildDeclaration {
769 form: FORM,
770 compiler: std::ptr::null(),
771 plugin_api: c"0.0.1".as_ptr(),
772 contract: c"0.0.2".as_ptr(),
773 source: 1,
774 };
775 let read = unsafe { read_declaration(&raw const broken) };
777 assert!(matches!(read, Err(PluginRefusal::Unreadable { form: FORM, .. })), "{read:?}");
778 }
779
780 #[test]
781 fn no_record_at_all_is_refused_as_unreadable() {
782 let read = unsafe { read_declaration(std::ptr::null()) };
784 assert!(matches!(read, Err(PluginRefusal::Unreadable { .. })), "{read:?}");
785 }
786
787 #[cfg(all(feature = "dynamic", not(target_arch = "wasm32")))]
788 mod dynamic_tests {
789 use super::*;
790 use std::path::Path;
791
792 #[test]
793 fn load_from_nonexistent_dir_returns_empty() {
794 let mut builder = PluginRegistryBuilder::new();
795 let results = load_plugins_from_dir(
796 Path::new("/tmp/razeri-nonexistent-plugin-dir-12345"),
797 &mut builder,
798 );
799 assert!(results.is_empty());
800 }
801
802 #[test]
803 fn load_from_empty_dir_returns_empty() {
804 let dir = tempfile::tempdir().unwrap();
805 let mut builder = PluginRegistryBuilder::new();
806 let results = load_plugins_from_dir(dir.path(), &mut builder);
807 assert!(results.is_empty());
808 }
809
810 #[test]
811 fn load_skips_non_library_files() {
812 let dir = tempfile::tempdir().unwrap();
813 std::fs::write(dir.path().join("notes.txt"), "hello").unwrap();
814 std::fs::write(dir.path().join("data.json"), "{}").unwrap();
815
816 let mut builder = PluginRegistryBuilder::new();
817 let results = load_plugins_from_dir(dir.path(), &mut builder);
818 assert!(results.is_empty());
819 }
820
821 #[test]
822 fn load_invalid_so_returns_failed() {
823 let dir = tempfile::tempdir().unwrap();
824 let lib_name = if cfg!(target_os = "macos") {
825 "bad.dylib"
826 } else if cfg!(target_os = "windows") {
827 "bad.dll"
828 } else {
829 "bad.so"
830 };
831 std::fs::write(dir.path().join(lib_name), "not a real library").unwrap();
832
833 let mut builder = PluginRegistryBuilder::new();
834 let results = load_plugins_from_dir(dir.path(), &mut builder);
835 assert_eq!(results.len(), 1);
836 assert!(
837 matches!(&results[0], PluginLoadResult::Failed { error, .. } if error.contains("Failed to load library"))
838 );
839 }
840 }
841}