Skip to main content

shape_runtime/plugins/
loader.rs

1//! Plugin Loader
2//!
3//! ADR-006 §2.7.29 W17-foreign-ffi 2026-05-23
4//!
5//! Handles dynamic loading of plugin shared libraries using libloading.
6
7use 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/// A TOML section claimed by a loaded plugin.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct ClaimedSection {
26    /// Section name (e.g., "native-dependencies")
27    pub name: String,
28    /// Whether this section is required (error if missing)
29    pub required: bool,
30}
31
32/// One declared capability exposed by a loaded plugin.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct PluginCapability {
35    /// Capability family.
36    pub kind: CapabilityKind,
37    /// Contract name (e.g., `shape.datasource`).
38    pub contract: String,
39    /// Contract version (e.g., `1`).
40    pub version: String,
41    /// Reserved capability flags.
42    pub flags: u64,
43}
44
45/// Information about a loaded plugin
46#[derive(Debug, Clone)]
47pub struct LoadedPlugin {
48    /// Plugin name
49    pub name: String,
50    /// Plugin version
51    pub version: String,
52    /// Plugin type
53    pub plugin_type: PluginType,
54    /// Plugin description
55    pub description: String,
56    /// Self-declared capability contracts.
57    pub capabilities: Vec<PluginCapability>,
58    /// TOML sections claimed by this plugin.
59    pub claimed_sections: Vec<ClaimedSection>,
60}
61
62impl LoadedPlugin {
63    /// Returns true if the plugin declares at least one capability with `kind`.
64    pub fn has_capability_kind(&self, kind: CapabilityKind) -> bool {
65        self.capabilities.iter().any(|cap| cap.kind == kind)
66    }
67
68    /// Returns the names of all claimed sections.
69    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
77/// Plugin Loader
78///
79/// Manages dynamic loading and unloading of Shape plugins.
80/// Keeps loaded libraries in memory to prevent unloading while in use.
81pub struct PluginLoader {
82    /// Loaded libraries (kept alive to prevent unloading)
83    loaded_libraries: HashMap<String, Library>,
84}
85
86impl PluginLoader {
87    /// Create a new plugin loader
88    pub fn new() -> Self {
89        Self {
90            loaded_libraries: HashMap::new(),
91        }
92    }
93
94    /// Load a plugin from a shared library file
95    ///
96    /// # Arguments
97    /// * `path` - Path to the shared library (.so, .dll, .dylib)
98    ///
99    /// # Returns
100    /// Information about the loaded plugin
101    ///
102    /// # Safety
103    /// Loading plugins executes arbitrary code. Only load from trusted sources.
104    pub fn load(&mut self, path: &Path) -> Result<LoadedPlugin> {
105        // Load the library
106        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        // Check ABI version — REQUIRED.
113        //
114        // v0.3 Round 8 W17-foreign-ffi (2026-05-23, supervisor (iv) ruling):
115        // fail-safe FFI version-mismatch — extensions built against an OLD
116        // ABI version MUST refuse to load with a structured error rather
117        // than silently degrading at the marshal boundary. The
118        // `shape_abi_version` symbol is REQUIRED (the previous code path
119        // silently accepted libraries without the symbol, which left the
120        // door open to silent ABI drift).
121        //
122        // Plugin authors generate this symbol automatically via the
123        // `shape_abi_v1::language_runtime_plugin!` / `data_source_plugin!`
124        // macros at `crates/shape-abi-v1/src/lib.rs:1493`.
125        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        // Get plugin info
159        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        // Extract info strings
178        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        // Load optional section claims
185        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![] // Optional — no section claims
197        };
198
199        // Store the library
200        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    /// Get the data source vtable for a loaded plugin
236    ///
237    /// # Arguments
238    /// * `name` - Name of the loaded plugin
239    ///
240    /// # Returns
241    /// The DataSourceVTable if plugin exists and is a data source
242    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            // SAFETY: vtable pointer is provided by the loaded module and expected static.
253            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    /// Get the output sink vtable for a loaded plugin
266    ///
267    /// # Arguments
268    /// * `name` - Name of the loaded plugin
269    ///
270    /// # Returns
271    /// The OutputSinkVTable if plugin exists and is an output sink
272    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            // SAFETY: vtable pointer is provided by the loaded module and expected static.
283            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    /// Get the base module vtable for a loaded plugin.
296    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            // SAFETY: vtable pointer is provided by the loaded module and expected static.
307            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    /// Get the language runtime vtable for a loaded plugin.
320    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    /// Unload a plugin
346    ///
347    /// Note: The library is actually unloaded when dropped. This removes it
348    /// from the loader's tracking.
349    pub fn unload(&mut self, name: &str) -> bool {
350        self.loaded_libraries.remove(name).is_some()
351    }
352
353    /// List all loaded plugins
354    pub fn loaded_plugins(&self) -> Vec<&str> {
355        self.loaded_libraries.keys().map(|s| s.as_str()).collect()
356    }
357
358    /// Check if a plugin is loaded
359    pub fn is_loaded(&self, name: &str) -> bool {
360        self.loaded_libraries.contains_key(name)
361    }
362
363    /// Load a data source plugin and return a ready-to-use wrapper
364    ///
365    /// This is a convenience method that combines loading the library,
366    /// getting the vtable, and creating the PluginDataSource wrapper.
367    ///
368    /// # Arguments
369    /// * `path` - Path to the shared library
370    /// * `config` - Configuration value for the plugin
371    ///
372    /// # Returns
373    /// Ready-to-use PluginDataSource wrapper
374    pub fn load_data_source(
375        &mut self,
376        path: &Path,
377        config: &serde_json::Value,
378    ) -> Result<super::PluginDataSource> {
379        // Load the library and get info
380        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        // Get the vtable
394        let vtable = self.get_data_source_vtable(&name)?;
395
396        // Create and return the wrapper
397        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                // Keep the library loaded for process lifetime.
441                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        // Language runtime extensions (e.g. Python/pyo3) may register process-level
505        // atexit handlers that reference code inside the loaded .so. If we dlclose
506        // the library before those handlers run at process exit, the process segfaults.
507        // Intentionally leak language runtime libraries so they remain mapped.
508        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                            // Leak: keep the library mapped for the process lifetime.
521                            std::mem::forget(lib);
522                            continue;
523                        }
524                    }
525                }
526            }
527            // Non-language-runtime libraries are dropped normally (dlclose).
528            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}