1use std::collections::HashMap;
8use std::ffi::CStr;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12use libloading::{Library, Symbol};
13
14use shape_abi_v1::{
15 ABI_VERSION, CAPABILITY_DATA_SOURCE, CAPABILITY_LANGUAGE_RUNTIME, CAPABILITY_MODULE,
16 CAPABILITY_OUTPUT_SINK, CapabilityKind, CapabilityManifest, DataSourceVTable, GetAbiVersionFn,
17 GetCapabilityManifestFn, GetCapabilityVTableFn, GetClaimedSectionsFn, GetPluginInfoFn,
18 LanguageRuntimeVTable, ModuleVTable, OutputSinkVTable, PluginType, SectionsManifest,
19};
20
21use shape_ast::error::{Result, ShapeError};
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct ClaimedSection {
26 pub name: String,
28 pub required: bool,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct PluginCapability {
35 pub kind: CapabilityKind,
37 pub contract: String,
39 pub version: String,
41 pub flags: u64,
43}
44
45#[derive(Debug, Clone)]
47pub struct LoadedPlugin {
48 pub name: String,
50 pub version: String,
52 pub plugin_type: PluginType,
54 pub description: String,
56 pub capabilities: Vec<PluginCapability>,
58 pub claimed_sections: Vec<ClaimedSection>,
60}
61
62impl LoadedPlugin {
63 pub fn has_capability_kind(&self, kind: CapabilityKind) -> bool {
65 self.capabilities.iter().any(|cap| cap.kind == kind)
66 }
67
68 pub fn claimed_section_names(&self) -> Vec<&str> {
70 self.claimed_sections
71 .iter()
72 .map(|s| s.name.as_str())
73 .collect()
74 }
75}
76
77pub struct PluginLoader {
82 loaded_libraries: HashMap<String, Library>,
84}
85
86impl PluginLoader {
87 pub fn new() -> Self {
89 Self {
90 loaded_libraries: HashMap::new(),
91 }
92 }
93
94 pub fn load(&mut self, path: &Path) -> Result<LoadedPlugin> {
105 let lib =
107 load_library_with_python_fallback(path).map_err(|e| ShapeError::RuntimeError {
108 message: format!("Failed to load plugin library '{}': {}", path.display(), e),
109 location: None,
110 })?;
111
112 let get_version = unsafe { lib.get::<GetAbiVersionFn>(b"shape_abi_version") }
126 .map_err(|e| ShapeError::RuntimeError {
127 message: format!(
128 "Plugin '{}' missing required 'shape_abi_version' export \
129 (fail-safe ABI version check, ADR-006 §2.7.4 / §2.7.5 — \
130 W17-foreign-ffi supervisor (iv) ruling). The host ABI \
131 version is {}. The extension must export \
132 `shape_abi_version()` — use the \
133 `shape_abi_v1::language_runtime_plugin!` macro to \
134 generate it automatically. Underlying loader error: {}",
135 path.display(),
136 ABI_VERSION,
137 e
138 ),
139 location: None,
140 })?;
141 let version = unsafe { get_version() };
142 if version != ABI_VERSION {
143 return Err(ShapeError::RuntimeError {
144 message: format!(
145 "Plugin '{}' ABI version mismatch: host expects v{}, \
146 plugin reports v{}. The plugin must be rebuilt against \
147 the current Shape ABI to load (fail-safe refuse-load \
148 per W17-foreign-ffi supervisor (iv) ruling — silent \
149 degradation at the marshal boundary is forbidden).",
150 path.display(),
151 ABI_VERSION,
152 version
153 ),
154 location: None,
155 });
156 }
157
158 let get_info: Symbol<GetPluginInfoFn> = unsafe {
160 lib.get(b"shape_plugin_info")
161 .map_err(|e| ShapeError::RuntimeError {
162 message: format!("Plugin missing 'shape_plugin_info' export: {}", e),
163 location: None,
164 })?
165 };
166
167 let info_ptr = unsafe { get_info() };
168 if info_ptr.is_null() {
169 return Err(ShapeError::RuntimeError {
170 message: "Plugin returned null PluginInfo".to_string(),
171 location: None,
172 });
173 }
174
175 let info = unsafe { &*info_ptr };
176
177 let name = read_c_string(info.name, "PluginInfo.name")?;
179 let version = read_c_string(info.version, "PluginInfo.version")?;
180 let description = read_c_string(info.description, "PluginInfo.description")?;
181
182 let capabilities = self.load_capabilities(&lib)?;
183
184 let claimed_sections = if let Ok(get_sections) =
186 unsafe { lib.get::<GetClaimedSectionsFn>(b"shape_claimed_sections") }
187 {
188 let manifest_ptr = unsafe { get_sections() };
189 if manifest_ptr.is_null() {
190 vec![]
191 } else {
192 let manifest = unsafe { &*manifest_ptr };
193 parse_sections_manifest(manifest)?
194 }
195 } else {
196 vec![] };
198
199 self.loaded_libraries.insert(name.clone(), lib);
201
202 Ok(LoadedPlugin {
203 name,
204 version,
205 plugin_type: info.plugin_type,
206 description,
207 capabilities,
208 claimed_sections,
209 })
210 }
211
212 fn load_capabilities(&self, lib: &Library) -> Result<Vec<PluginCapability>> {
213 let get_manifest =
214 unsafe { lib.get::<GetCapabilityManifestFn>(b"shape_capability_manifest") }.map_err(
215 |e| ShapeError::RuntimeError {
216 message: format!(
217 "Plugin missing required 'shape_capability_manifest' export: {}",
218 e
219 ),
220 location: None,
221 },
222 )?;
223
224 let manifest_ptr = unsafe { get_manifest() };
225 if manifest_ptr.is_null() {
226 return Err(ShapeError::RuntimeError {
227 message: "Plugin returned null CapabilityManifest".to_string(),
228 location: None,
229 });
230 }
231 let manifest = unsafe { &*manifest_ptr };
232 parse_capability_manifest(manifest)
233 }
234
235 pub fn get_data_source_vtable(&self, name: &str) -> Result<&'static DataSourceVTable> {
243 let lib = self
244 .loaded_libraries
245 .get(name)
246 .ok_or_else(|| ShapeError::RuntimeError {
247 message: format!("Plugin '{}' not loaded", name),
248 location: None,
249 })?;
250
251 if let Some(vtable_ptr) = try_capability_vtable(lib, CAPABILITY_DATA_SOURCE)? {
252 return Ok(unsafe { &*(vtable_ptr as *const DataSourceVTable) });
254 }
255
256 Err(ShapeError::RuntimeError {
257 message: format!(
258 "Plugin '{}' does not provide capability vtable for '{}'",
259 name, CAPABILITY_DATA_SOURCE
260 ),
261 location: None,
262 })
263 }
264
265 pub fn get_output_sink_vtable(&self, name: &str) -> Result<&'static OutputSinkVTable> {
273 let lib = self
274 .loaded_libraries
275 .get(name)
276 .ok_or_else(|| ShapeError::RuntimeError {
277 message: format!("Plugin '{}' not loaded", name),
278 location: None,
279 })?;
280
281 if let Some(vtable_ptr) = try_capability_vtable(lib, CAPABILITY_OUTPUT_SINK)? {
282 return Ok(unsafe { &*(vtable_ptr as *const OutputSinkVTable) });
284 }
285
286 Err(ShapeError::RuntimeError {
287 message: format!(
288 "Plugin '{}' does not provide capability vtable for '{}'",
289 name, CAPABILITY_OUTPUT_SINK
290 ),
291 location: None,
292 })
293 }
294
295 pub fn get_module_vtable(&self, name: &str) -> Result<&'static ModuleVTable> {
297 let lib = self
298 .loaded_libraries
299 .get(name)
300 .ok_or_else(|| ShapeError::RuntimeError {
301 message: format!("Plugin '{}' not loaded", name),
302 location: None,
303 })?;
304
305 if let Some(vtable_ptr) = try_capability_vtable(lib, CAPABILITY_MODULE)? {
306 return Ok(unsafe { &*(vtable_ptr as *const ModuleVTable) });
308 }
309
310 Err(ShapeError::RuntimeError {
311 message: format!(
312 "Plugin '{}' does not provide capability vtable for '{}'",
313 name, CAPABILITY_MODULE
314 ),
315 location: None,
316 })
317 }
318
319 pub fn get_language_runtime_vtable(
321 &self,
322 name: &str,
323 ) -> Result<&'static LanguageRuntimeVTable> {
324 let lib = self
325 .loaded_libraries
326 .get(name)
327 .ok_or_else(|| ShapeError::RuntimeError {
328 message: format!("Plugin '{}' not loaded", name),
329 location: None,
330 })?;
331
332 if let Some(vtable_ptr) = try_capability_vtable(lib, CAPABILITY_LANGUAGE_RUNTIME)? {
333 return Ok(unsafe { &*(vtable_ptr as *const LanguageRuntimeVTable) });
334 }
335
336 Err(ShapeError::RuntimeError {
337 message: format!(
338 "Plugin '{}' does not provide capability vtable for '{}'",
339 name, CAPABILITY_LANGUAGE_RUNTIME
340 ),
341 location: None,
342 })
343 }
344
345 pub fn unload(&mut self, name: &str) -> bool {
350 self.loaded_libraries.remove(name).is_some()
351 }
352
353 pub fn loaded_plugins(&self) -> Vec<&str> {
355 self.loaded_libraries.keys().map(|s| s.as_str()).collect()
356 }
357
358 pub fn is_loaded(&self, name: &str) -> bool {
360 self.loaded_libraries.contains_key(name)
361 }
362
363 pub fn load_data_source(
375 &mut self,
376 path: &Path,
377 config: &serde_json::Value,
378 ) -> Result<super::PluginDataSource> {
379 let info = self.load(path)?;
381 let name = info.name.clone();
382
383 if !info.has_capability_kind(CapabilityKind::DataSource) {
384 return Err(ShapeError::RuntimeError {
385 message: format!(
386 "Plugin '{}' does not declare data source capability",
387 info.name
388 ),
389 location: None,
390 });
391 }
392
393 let vtable = self.get_data_source_vtable(&name)?;
395
396 super::PluginDataSource::new(name, vtable, config)
398 }
399}
400
401fn load_library_with_python_fallback(path: &Path) -> std::result::Result<Library, String> {
402 let initial = unsafe { Library::new(path) };
403 let initial_error = match initial {
404 Ok(lib) => return Ok(lib),
405 Err(err) => err,
406 };
407 let initial_msg = initial_error.to_string();
408
409 if !should_try_python_fallback(&initial_msg) {
410 return Err(initial_msg);
411 }
412
413 if !preload_python_shared_library() {
414 return Err(initial_msg);
415 }
416
417 match unsafe { Library::new(path) } {
418 Ok(lib) => Ok(lib),
419 Err(retry_err) => Err(format!(
420 "{} (retry after python preload failed: {})",
421 initial_msg, retry_err
422 )),
423 }
424}
425
426fn should_try_python_fallback(error_message: &str) -> bool {
427 let lowered = error_message.to_ascii_lowercase();
428 lowered.contains("libpython") || lowered.contains("python.framework")
429}
430
431fn preload_python_shared_library() -> bool {
432 let candidates = discover_python_shared_library_candidates();
433 for candidate in candidates {
434 match unsafe { Library::new(&candidate) } {
435 Ok(lib) => {
436 tracing::info!(
437 "preloaded python runtime library for extension loading fallback: {}",
438 candidate.display()
439 );
440 std::mem::forget(lib);
442 return true;
443 }
444 Err(err) => {
445 tracing::debug!(
446 "failed to preload python runtime candidate '{}': {}",
447 candidate.display(),
448 err
449 );
450 }
451 }
452 }
453 false
454}
455
456fn discover_python_shared_library_candidates() -> Vec<PathBuf> {
457 let python = std::env::var("PYO3_PYTHON").unwrap_or_else(|_| "python3".to_string());
458 let script = r#"import os, sys, sysconfig
459cands = []
460libdir = sysconfig.get_config_var("LIBDIR")
461ldlibrary = sysconfig.get_config_var("LDLIBRARY")
462if libdir and ldlibrary:
463 cands.append(os.path.join(libdir, ldlibrary))
464if libdir:
465 for name in ("libpython3.so", "libpython3.so.1.0", "libpython3.dylib"):
466 cands.append(os.path.join(libdir, name))
467for base in {sys.base_prefix, sys.prefix}:
468 if not base:
469 continue
470 for rel in ("lib", "lib64"):
471 d = os.path.join(base, rel)
472 if ldlibrary:
473 cands.append(os.path.join(d, ldlibrary))
474seen = set()
475for cand in cands:
476 if not cand:
477 continue
478 real = os.path.realpath(cand)
479 if real in seen:
480 continue
481 seen.add(real)
482 if os.path.exists(real):
483 print(real)
484"#;
485
486 let output = Command::new(&python).arg("-c").arg(script).output();
487 let Ok(output) = output else {
488 return Vec::new();
489 };
490 if !output.status.success() {
491 return Vec::new();
492 }
493
494 String::from_utf8_lossy(&output.stdout)
495 .lines()
496 .map(str::trim)
497 .filter(|line| !line.is_empty())
498 .map(PathBuf::from)
499 .collect()
500}
501
502impl Drop for PluginLoader {
503 fn drop(&mut self) {
504 for (_name, lib) in self.loaded_libraries.drain() {
509 if let Ok(get_manifest) =
510 unsafe { lib.get::<GetCapabilityManifestFn>(b"shape_capability_manifest") }
511 {
512 let manifest_ptr = unsafe { get_manifest() };
513 if !manifest_ptr.is_null() {
514 let manifest = unsafe { &*manifest_ptr };
515 if let Ok(caps) = parse_capability_manifest(manifest) {
516 if caps
517 .iter()
518 .any(|c| c.kind == CapabilityKind::LanguageRuntime)
519 {
520 std::mem::forget(lib);
522 continue;
523 }
524 }
525 }
526 }
527 drop(lib);
529 }
530 }
531}
532
533impl Default for PluginLoader {
534 fn default() -> Self {
535 Self::new()
536 }
537}
538
539fn try_capability_vtable(lib: &Library, contract: &str) -> Result<Option<*const std::ffi::c_void>> {
540 let get_vtable_fn = unsafe { lib.get::<GetCapabilityVTableFn>(b"shape_capability_vtable") };
541 let Ok(get_vtable_fn) = get_vtable_fn else {
542 return Ok(None);
543 };
544
545 let vtable_ptr = unsafe { get_vtable_fn(contract.as_ptr(), contract.len()) };
546 if vtable_ptr.is_null() {
547 return Ok(None);
548 }
549 Ok(Some(vtable_ptr))
550}
551
552fn parse_capability_manifest(manifest: &CapabilityManifest) -> Result<Vec<PluginCapability>> {
553 if manifest.capabilities_len == 0 {
554 return Err(ShapeError::RuntimeError {
555 message: "CapabilityManifest must contain at least one capability".to_string(),
556 location: None,
557 });
558 }
559 if manifest.capabilities.is_null() {
560 return Err(ShapeError::RuntimeError {
561 message: "CapabilityManifest.capabilities is null".to_string(),
562 location: None,
563 });
564 }
565
566 let caps =
567 unsafe { std::slice::from_raw_parts(manifest.capabilities, manifest.capabilities_len) };
568 let mut parsed = Vec::with_capacity(caps.len());
569 for cap in caps {
570 parsed.push(PluginCapability {
571 kind: cap.kind,
572 contract: read_c_string(cap.contract, "CapabilityDescriptor.contract")?,
573 version: read_c_string(cap.version, "CapabilityDescriptor.version")?,
574 flags: cap.flags,
575 });
576 }
577 Ok(parsed)
578}
579
580pub fn parse_sections_manifest(manifest: &SectionsManifest) -> Result<Vec<ClaimedSection>> {
581 if manifest.sections_len == 0 {
582 return Ok(vec![]);
583 }
584 if manifest.sections.is_null() {
585 return Err(ShapeError::RuntimeError {
586 message: "SectionsManifest.sections is null but sections_len > 0".to_string(),
587 location: None,
588 });
589 }
590
591 let claims = unsafe { std::slice::from_raw_parts(manifest.sections, manifest.sections_len) };
592 let mut parsed = Vec::with_capacity(claims.len());
593 for claim in claims {
594 parsed.push(ClaimedSection {
595 name: read_c_string(claim.name, "SectionClaim.name")?,
596 required: claim.required,
597 });
598 }
599 Ok(parsed)
600}
601
602fn read_c_string(ptr: *const std::ffi::c_char, field: &str) -> Result<String> {
603 if ptr.is_null() {
604 return Err(ShapeError::RuntimeError {
605 message: format!("{} is null", field),
606 location: None,
607 });
608 }
609
610 Ok(unsafe { CStr::from_ptr(ptr) }.to_string_lossy().to_string())
611}
612
613#[cfg(test)]
614mod tests {
615 use super::*;
616 use shape_abi_v1::{CAPABILITY_MODULE, CapabilityDescriptor};
617
618 #[test]
619 fn test_plugin_loader_new() {
620 let loader = PluginLoader::new();
621 assert!(loader.loaded_plugins().is_empty());
622 }
623
624 #[test]
625 fn test_is_loaded_false() {
626 let loader = PluginLoader::new();
627 assert!(!loader.is_loaded("nonexistent"));
628 }
629
630 #[test]
631 fn test_should_try_python_fallback_matches_libpython_errors() {
632 assert!(should_try_python_fallback(
633 "libpython3.13.so.1.0: cannot open shared object file"
634 ));
635 assert!(should_try_python_fallback(
636 "Library not loaded: @rpath/Python.framework/Versions/3.12/Python"
637 ));
638 assert!(!should_try_python_fallback(
639 "undefined symbol: sqlite3_open"
640 ));
641 }
642
643 #[test]
644 fn test_parse_capability_manifest() {
645 static CAPS: [CapabilityDescriptor; 2] = [
646 CapabilityDescriptor {
647 kind: CapabilityKind::DataSource,
648 contract: c"shape.datasource".as_ptr(),
649 version: c"1".as_ptr(),
650 flags: 0,
651 },
652 CapabilityDescriptor {
653 kind: CapabilityKind::Compute,
654 contract: c"shape.compute".as_ptr(),
655 version: c"1".as_ptr(),
656 flags: 42,
657 },
658 ];
659 static MANIFEST: CapabilityManifest = CapabilityManifest {
660 capabilities: CAPS.as_ptr(),
661 capabilities_len: CAPS.len(),
662 };
663
664 let parsed = parse_capability_manifest(&MANIFEST).expect("manifest should parse");
665 assert_eq!(parsed.len(), 2);
666 assert_eq!(parsed[0].contract, "shape.datasource");
667 assert_eq!(parsed[1].kind, CapabilityKind::Compute);
668 assert_eq!(parsed[1].flags, 42);
669 }
670
671 #[test]
672 fn test_parse_capability_manifest_rejects_empty() {
673 static MANIFEST: CapabilityManifest = CapabilityManifest {
674 capabilities: std::ptr::null(),
675 capabilities_len: 0,
676 };
677 let result = parse_capability_manifest(&MANIFEST);
678 assert!(result.is_err());
679 }
680
681 #[test]
682 fn test_module_contract_constant_is_expected() {
683 assert_eq!(CAPABILITY_MODULE, "shape.module");
684 }
685
686 #[test]
687 fn test_parse_sections_manifest_valid() {
688 use shape_abi_v1::SectionClaim as AbiSectionClaim;
689
690 static CLAIMS: [AbiSectionClaim; 2] = [
691 AbiSectionClaim {
692 name: c"native-dependencies".as_ptr(),
693 required: false,
694 },
695 AbiSectionClaim {
696 name: c"custom-config".as_ptr(),
697 required: true,
698 },
699 ];
700 static MANIFEST: SectionsManifest = SectionsManifest {
701 sections: CLAIMS.as_ptr(),
702 sections_len: CLAIMS.len(),
703 };
704
705 let parsed = parse_sections_manifest(&MANIFEST).expect("should parse");
706 assert_eq!(parsed.len(), 2);
707 assert_eq!(parsed[0].name, "native-dependencies");
708 assert!(!parsed[0].required);
709 assert_eq!(parsed[1].name, "custom-config");
710 assert!(parsed[1].required);
711 }
712
713 #[test]
714 fn test_parse_sections_manifest_empty() {
715 static MANIFEST: SectionsManifest = SectionsManifest {
716 sections: std::ptr::null(),
717 sections_len: 0,
718 };
719 let parsed = parse_sections_manifest(&MANIFEST).expect("empty should parse");
720 assert!(parsed.is_empty());
721 }
722}