Skip to main content

lean_rs/module/
capability.rs

1//! Build-script paired capability opener.
2//!
3//! This module is the runtime half of
4//! [`lean_toolchain::CargoLeanCapability`]. It lets shipped crates open the
5//! dylib path their `build.rs` embedded without repeating environment-path
6//! lookup and module-initialization names at every call site.
7//!
8//! The [`LeanBuiltCapability`] descriptor itself is link-free and lives in
9//! `lean-toolchain` so the worker parent crate can consume it without
10//! relinking `libleanshared`. It is re-exported here for source compatibility.
11
12// SAFETY DOC: the only unsafe block in this file is the final transition from
13// manifest-checked ABI metadata to `LeanModule::exported_unchecked`. The safe
14// public method validates the manifest signature and function/global kind
15// before reaching that constructor.
16#![allow(unsafe_code)]
17
18use std::collections::HashMap;
19use std::path::Path;
20
21use super::preflight::{CapabilityManifest, LeanRuntimePreflight, manifest_error_to_lean_error, report_into_error};
22use super::{
23    DecodeCallResult, LeanArgs, LeanExported, LeanLibrary, LeanLibraryBundle, LeanLibraryDependency, LeanModule,
24};
25use crate::error::{LeanError, LeanResult};
26use crate::runtime::LeanRuntime;
27use lean_toolchain::{LeanExportSignature, LeanExportSymbolKind};
28
29// Build-script descriptor lives in `lean-toolchain` (below `lean-rs`) so the
30// worker parent crate can construct and consume it without relinking
31// `libleanshared`. Re-exported here for the historical `lean_rs::LeanBuiltCapability`
32// path.
33pub use lean_toolchain::{BuiltCapabilityArtifact, LeanBuiltCapability, LeanBuiltCapabilityError};
34
35fn built_capability_error_to_lean_error(err: &LeanBuiltCapabilityError) -> LeanError {
36    LeanError::module_init(err.to_string())
37}
38
39/// Opened Lean capability whose dylib path and initializer names came from
40/// the build-script pairing.
41pub struct LeanCapability<'lean> {
42    bundle: LeanLibraryBundle<'lean>,
43    package: String,
44    module: String,
45    export_signatures: HashMap<String, LeanExportSignature>,
46}
47
48/// Typed failure from manifest-backed checked export lookup.
49#[derive(Debug)]
50pub enum LeanCheckedExportError {
51    /// The manifest has no trusted signature entry for the requested symbol.
52    MissingSignatureMetadata { symbol: String },
53    /// The manifest signature does not match the requested Rust call shape.
54    SignatureMismatch {
55        symbol: String,
56        expected: Box<LeanExportSignature>,
57        manifest: Box<LeanExportSignature>,
58    },
59    /// The manifest's function/global classification does not match the dylib.
60    SymbolKindMismatch {
61        symbol: String,
62        manifest: LeanExportSymbolKind,
63        actual: LeanExportSymbolKind,
64    },
65    /// The manifest entry exists, but the symbol is absent from the loaded library.
66    MissingSymbol { symbol: String, source: LeanError },
67    /// The initialized module could not be reopened before lookup.
68    Module(LeanError),
69}
70
71impl std::fmt::Display for LeanCheckedExportError {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        match self {
74            Self::MissingSignatureMetadata { symbol } => {
75                write!(f, "missing trusted export signature metadata for symbol '{symbol}'")
76            }
77            Self::SignatureMismatch {
78                symbol,
79                expected,
80                manifest,
81            } => write!(
82                f,
83                "export signature mismatch for symbol '{symbol}': requested {expected:?}, manifest has {manifest:?}"
84            ),
85            Self::SymbolKindMismatch {
86                symbol,
87                manifest,
88                actual,
89            } => write!(
90                f,
91                "export symbol kind mismatch for symbol '{symbol}': manifest has {manifest:?}, dylib has {actual:?}"
92            ),
93            Self::MissingSymbol { symbol, source } => {
94                write!(
95                    f,
96                    "manifest-backed export symbol '{symbol}' is missing from the loaded library: {source}"
97                )
98            }
99            Self::Module(err) => write!(f, "failed to initialize module before checked export lookup: {err}"),
100        }
101    }
102}
103
104impl std::error::Error for LeanCheckedExportError {
105    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
106        match self {
107            Self::MissingSymbol { source, .. } | Self::Module(source) => Some(source),
108            Self::MissingSignatureMetadata { .. }
109            | Self::SignatureMismatch { .. }
110            | Self::SymbolKindMismatch { .. } => None,
111        }
112    }
113}
114
115impl<'lean> LeanCapability<'lean> {
116    /// Open and initialize a build-script produced Lean capability from its
117    /// JSON artifact manifest.
118    ///
119    /// # Errors
120    ///
121    /// Returns [`LeanError`] when the manifest path cannot be resolved, the
122    /// manifest is missing, malformed, or unsupported, or the bundle described
123    /// by the manifest cannot be opened.
124    #[allow(clippy::needless_pass_by_value)]
125    pub fn from_build_manifest(runtime: &'lean LeanRuntime, spec: LeanBuiltCapability) -> LeanResult<Self> {
126        let report = LeanRuntimePreflight::new(spec.clone()).check();
127        if !report.is_ok() {
128            return Err(report_into_error(report));
129        }
130        let manifest_path = spec
131            .resolved_manifest_path()
132            .map_err(|err| built_capability_error_to_lean_error(&err))?;
133        let manifest = CapabilityManifest::read(&manifest_path).map_err(manifest_error_to_lean_error)?;
134        Self::open_with_dependencies_and_exports(
135            runtime,
136            manifest.primary_dylib,
137            manifest.package,
138            manifest.module,
139            manifest.dependencies,
140            manifest.exports,
141        )
142    }
143
144    /// Open and initialize a build-script produced Lean capability from a
145    /// direct dylib path.
146    ///
147    /// This compatibility path cannot carry dependency ordering by itself.
148    /// Prefer [`Self::from_build_manifest`] for shipped crates.
149    ///
150    /// # Errors
151    ///
152    /// Returns [`LeanError`] when the dylib path cannot be resolved, the
153    /// dynamic loader cannot open it, or the configured module initializer
154    /// fails.
155    pub fn from_build_env(runtime: &'lean LeanRuntime, mut spec: LeanBuiltCapability) -> LeanResult<Self> {
156        let dylib_path = spec
157            .dylib_path()
158            .map_err(|err| built_capability_error_to_lean_error(&err))?;
159        let package = spec.take_package_name().ok_or_else(|| {
160            LeanError::linking("LeanBuiltCapability is missing the Lake package name; call `.package(...)`")
161        })?;
162        let module = spec.take_module_name().ok_or_else(|| {
163            LeanError::linking("LeanBuiltCapability is missing the root Lean module name; call `.module(...)`")
164        })?;
165        let dependencies = spec.take_dependencies();
166        Self::open_with_dependencies_and_exports(runtime, dylib_path, package, module, dependencies, [])
167    }
168
169    /// Open and initialize a capability from an explicit dylib path and
170    /// initializer names.
171    ///
172    /// # Errors
173    ///
174    /// Returns [`LeanError`] when the dynamic loader cannot open the dylib or
175    /// the configured module initializer fails.
176    pub fn open(
177        runtime: &'lean LeanRuntime,
178        dylib_path: impl AsRef<Path>,
179        package: impl Into<String>,
180        module: impl Into<String>,
181    ) -> LeanResult<Self> {
182        let package = package.into();
183        let module = module.into();
184        Self::open_with_dependencies_and_exports(runtime, dylib_path, package, module, [], [])
185    }
186
187    /// Open and initialize a capability with explicitly described dependency
188    /// dylibs.
189    ///
190    /// This is the runtime form artifact manifests feed. Use
191    /// [`LeanCapability::from_build_manifest`] for shipped crates when
192    /// build-script metadata is available.
193    ///
194    /// # Errors
195    ///
196    /// Returns [`LeanError`] when a dependency or primary dylib cannot be
197    /// loaded, or when a dependency or primary module initializer fails.
198    pub fn open_with_dependencies(
199        runtime: &'lean LeanRuntime,
200        dylib_path: impl AsRef<Path>,
201        package: impl Into<String>,
202        module: impl Into<String>,
203        dependencies: impl IntoIterator<Item = LeanLibraryDependency>,
204    ) -> LeanResult<Self> {
205        Self::open_with_dependencies_and_exports(runtime, dylib_path, package, module, dependencies, [])
206    }
207
208    fn open_with_dependencies_and_exports(
209        runtime: &'lean LeanRuntime,
210        dylib_path: impl AsRef<Path>,
211        package: impl Into<String>,
212        module: impl Into<String>,
213        dependencies: impl IntoIterator<Item = LeanLibraryDependency>,
214        export_signatures: impl IntoIterator<Item = LeanExportSignature>,
215    ) -> LeanResult<Self> {
216        let package = package.into();
217        let module = module.into();
218        let bundle = LeanLibraryBundle::open(runtime, dylib_path, dependencies)?;
219        let _module = bundle.initialize_module(&package, &module)?;
220        let export_signatures = export_signatures
221            .into_iter()
222            .map(|signature| (signature.symbol().to_owned(), signature))
223            .collect();
224        Ok(Self {
225            bundle,
226            package,
227            module,
228            export_signatures,
229        })
230    }
231
232    /// Look up an exported symbol only if trusted manifest ABI metadata
233    /// exactly matches the requested Rust call shape.
234    ///
235    /// Unlike [`LeanModule::exported_unchecked`], this method is safe: the
236    /// caller supplies only a symbol name and Rust `Args`/`R` types. The ABI
237    /// assertion comes from the capability manifest parsed at load time.
238    ///
239    /// # Errors
240    ///
241    /// Returns [`LeanCheckedExportError`] when metadata is missing, the Rust
242    /// shape disagrees with the manifest, the manifest's function/global kind
243    /// disagrees with the loaded dylib, or the symbol cannot be resolved.
244    pub fn exported<Args, R>(&self, name: &str) -> Result<LeanExported<'lean, '_, Args, R>, LeanCheckedExportError>
245    where
246        Args: LeanArgs<'lean>,
247        R: DecodeCallResult<'lean>,
248    {
249        let manifest =
250            self.export_signatures
251                .get(name)
252                .ok_or_else(|| LeanCheckedExportError::MissingSignatureMetadata {
253                    symbol: name.to_owned(),
254                })?;
255        let expected = match manifest.kind() {
256            LeanExportSymbolKind::Function => {
257                LeanExportSignature::function(name, Args::export_abi_args(), R::export_abi_return())
258            }
259            LeanExportSymbolKind::Global if Args::ARITY == 0 && !R::EXPECTS_IO_RESULT => {
260                LeanExportSignature::global(name, R::export_abi_return())
261            }
262            LeanExportSymbolKind::Global => {
263                LeanExportSignature::function(name, Args::export_abi_args(), R::export_abi_return())
264            }
265        };
266        if &expected != manifest {
267            return Err(LeanCheckedExportError::SignatureMismatch {
268                symbol: name.to_owned(),
269                expected: Box::new(expected),
270                manifest: Box::new(manifest.clone()),
271            });
272        }
273
274        let actual_kind = if self.library().globals().contains(name) {
275            LeanExportSymbolKind::Global
276        } else {
277            LeanExportSymbolKind::Function
278        };
279        if manifest.kind() != actual_kind {
280            return Err(LeanCheckedExportError::SymbolKindMismatch {
281                symbol: name.to_owned(),
282                manifest: manifest.kind(),
283                actual: actual_kind,
284            });
285        }
286
287        let module = self.module().map_err(LeanCheckedExportError::Module)?;
288        // SAFETY: the manifest signature matched `Args`/`R` exactly, and the
289        // function/global classification matched the loaded dylib before this
290        // unchecked constructor was reached.
291        unsafe { module.exported_unchecked::<Args, R>(name) }.map_err(|source| LeanCheckedExportError::MissingSymbol {
292            symbol: name.to_owned(),
293            source,
294        })
295    }
296
297    /// Return an initialized module handle.
298    ///
299    /// Lean module initializers are idempotent, so obtaining the handle after
300    /// construction is cheap and safe.
301    ///
302    /// # Errors
303    ///
304    /// Returns [`LeanError`] if the module initializer unexpectedly fails when
305    /// invoked again.
306    pub fn module(&self) -> LeanResult<LeanModule<'lean, '_>> {
307        self.bundle.initialize_module(&self.package, &self.module)
308    }
309
310    /// Borrow the underlying library for advanced symbol access.
311    #[must_use]
312    pub fn library(&self) -> &LeanLibrary<'lean> {
313        self.bundle.library()
314    }
315
316    /// Borrow the bundle that anchors this capability and its dependencies.
317    #[must_use]
318    pub fn bundle(&self) -> &LeanLibraryBundle<'lean> {
319        &self.bundle
320    }
321
322    /// Lake package name used by the initializer.
323    #[must_use]
324    pub fn package_name(&self) -> &str {
325        &self.package
326    }
327
328    /// Root Lean module name used by the initializer.
329    #[must_use]
330    pub fn module_name(&self) -> &str {
331        &self.module
332    }
333
334    /// Trusted export signatures parsed from the capability manifest.
335    pub fn export_signatures(&self) -> impl Iterator<Item = &LeanExportSignature> {
336        self.export_signatures.values()
337    }
338}
339
340#[cfg(test)]
341#[allow(clippy::expect_used, clippy::panic)]
342mod tests {
343    use super::{
344        BuiltCapabilityArtifact, CapabilityManifest, LeanBuiltCapability, LeanBuiltCapabilityError,
345        LeanLibraryDependency,
346    };
347    use std::fs;
348    use std::path::PathBuf;
349
350    #[test]
351    fn built_capability_path_is_resolved_without_runtime_env() {
352        let spec = LeanBuiltCapability::path("/tmp/libcap.so")
353            .env_var("LEAN_RS_CAPABILITY_CAP_DYLIB")
354            .package("pkg")
355            .module("Cap");
356
357        let path = match spec.dylib_path() {
358            Ok(path) => path,
359            Err(err) => panic!("expected path, got {err}"),
360        };
361        assert_eq!(path, std::path::PathBuf::from("/tmp/libcap.so"));
362        assert_eq!(spec.package_name(), Some("pkg"));
363        assert_eq!(spec.module_name(), Some("Cap"));
364    }
365
366    #[test]
367    fn missing_runtime_env_is_typed() {
368        let spec = LeanBuiltCapability::env("LEAN_RS_TEST_MISSING_CAPABILITY_DYLIB")
369            .package("pkg")
370            .module("Cap");
371        let err = match spec.dylib_path() {
372            Ok(path) => panic!("expected missing env error, got {}", path.display()),
373            Err(err) => err,
374        };
375        assert!(matches!(
376            err,
377            LeanBuiltCapabilityError::EnvVarNotSet {
378                kind: BuiltCapabilityArtifact::Dylib,
379                ..
380            }
381        ));
382    }
383
384    #[test]
385    fn missing_runtime_manifest_env_is_typed() {
386        let spec = LeanBuiltCapability::manifest_env("LEAN_RS_TEST_MISSING_CAPABILITY_MANIFEST");
387        let err = match spec.resolved_manifest_path() {
388            Ok(path) => panic!("expected missing manifest env error, got {}", path.display()),
389            Err(err) => err,
390        };
391        assert!(matches!(
392            err,
393            LeanBuiltCapabilityError::EnvVarNotSet {
394                kind: BuiltCapabilityArtifact::Manifest,
395                ..
396            }
397        ));
398    }
399
400    #[test]
401    fn manifest_descriptor_parses_dependencies() {
402        let path = temp_manifest_path("manifest_descriptor_parses_dependencies");
403        write_manifest(
404            &path,
405            r#"{
406  "schema_version": 2,
407  "target_name": "Cap",
408  "package": "pkg",
409  "module": "Cap",
410  "primary_dylib": "/tmp/libcap.so",
411  "exports": [],
412  "dependencies": [
413    {
414      "dylib_path": "/tmp/libdep.so",
415      "export_symbols_for_dependents": true,
416      "initializer": { "package": "dep_pkg", "module": "Dep" }
417    }
418  ]
419}"#,
420        );
421
422        let manifest = match CapabilityManifest::read(&path) {
423            Ok(manifest) => manifest,
424            Err(err) => panic!("expected manifest to parse, got {err}"),
425        };
426        assert_eq!(manifest.primary_dylib, PathBuf::from("/tmp/libcap.so"));
427        assert_eq!(manifest.package, "pkg");
428        assert_eq!(manifest.module, "Cap");
429        assert_eq!(manifest.dependencies.len(), 1);
430        let Some(dependency) = manifest.dependencies.first() else {
431            panic!("expected one dependency");
432        };
433        assert!(dependency.exports_symbols_for_dependents());
434        assert_eq!(dependency.path_ref(), std::path::Path::new("/tmp/libdep.so"));
435        let Some(initializer) = dependency.module_initializer() else {
436            panic!("expected dependency initializer");
437        };
438        assert_eq!(initializer.package_name(), "dep_pkg");
439        assert_eq!(initializer.module_name(), "Dep");
440    }
441
442    #[test]
443    fn unsupported_manifest_schema_is_typed() {
444        let path = temp_manifest_path("unsupported_manifest_schema_is_typed");
445        write_manifest(
446            &path,
447            r#"{
448  "schema_version": 999,
449  "package": "pkg",
450  "module": "Cap",
451  "primary_dylib": "/tmp/libcap.so",
452  "exports": []
453}"#,
454        );
455
456        let Err(err) = CapabilityManifest::read(&path) else {
457            panic!("expected unsupported schema error");
458        };
459        assert_eq!(err.code(), crate::LeanLoaderDiagnosticCode::UnsupportedManifestSchema);
460        assert!(err.message().contains("unsupported Lean capability manifest schema"));
461    }
462
463    #[test]
464    fn built_capability_records_dependency_descriptors() {
465        let spec = LeanBuiltCapability::path("/tmp/libcap.so").dependency(
466            LeanLibraryDependency::path("/tmp/libdep.so")
467                .export_symbols_for_dependents()
468                .initializer("dep_pkg", "Dep"),
469        );
470
471        let dependencies = spec.dependency_descriptors();
472        assert_eq!(dependencies.len(), 1);
473        let Some(dependency) = dependencies.first() else {
474            panic!("expected one dependency descriptor");
475        };
476        assert!(dependency.exports_symbols_for_dependents());
477        let Some(initializer) = dependency.module_initializer() else {
478            panic!("dependency initializer is recorded");
479        };
480        assert_eq!(initializer.package_name(), "dep_pkg");
481        assert_eq!(initializer.module_name(), "Dep");
482    }
483
484    fn temp_manifest_path(name: &str) -> PathBuf {
485        let dir = std::env::temp_dir().join(format!("lean-rs-manifest-{}-{name}", std::process::id()));
486        drop(fs::remove_dir_all(&dir));
487        fs::create_dir_all(&dir).expect("create manifest test dir");
488        dir.join("capability.json")
489    }
490
491    fn write_manifest(path: &std::path::Path, contents: &str) {
492        fs::write(path, contents).expect("write manifest fixture");
493    }
494}