Skip to main content

lean_rs/module/
library.rs

1//! RAII handle over a Lake-built Lean shared object.
2//!
3//! [`LeanLibrary`] owns a [`libloading::Library`] for the duration of its
4//! scope and exposes a single safe operation that callers actually want:
5//! initialize a named Lean module out of it. The dlopen step, the Lake
6//! symbol-mangling convention, the `IO Unit` decoding, and the
7//! `builtin` flag policy are hidden inside the implementation; the
8//! interface mentions only the human-readable package and module names.
9//!
10//! Construction requires a `&'lean LeanRuntime` borrow. Use-before-init
11//! is therefore structurally impossible: a caller cannot build a
12//! [`LeanLibrary`] without holding the proof that
13//! [`crate::LeanRuntime::init`] has succeeded.
14//!
15//! ## Symbol-table walk at `open`
16//!
17//! Lean compiles `def x : T := constant`—a nullary export whose body
18//! reduces to a constant—to a persistent `lean_object*` global variable
19//! (`lean_mark_persistent` at module init), not a callable function.
20//! Calling such a symbol as a function pointer SIGBUSes. To make the
21//! distinction invisible to callers, [`LeanLibrary::open`] reads the
22//! dylib's bytes once, walks the export table with the [`object`] crate,
23//! and records the names of data-section exports as `globals`.
24//! [`LeanModule::exported_unchecked`](super::loaded::LeanModule::exported_unchecked) consults
25//! the set to dispatch function-vs-global at call time.
26
27// SAFETY DOC: every `unsafe { ... }` block in this file carries its own
28// `// SAFETY:` comment. The blanket allow exists because this is the
29// single `pub` doorway into the dlopen/dlsym path; per
30// `docs/architecture/01-safety-model.md` the opt-out lives at the
31// smallest scope that compiles.
32#![allow(unsafe_code)]
33
34use std::collections::HashSet;
35use std::ffi::c_void;
36use std::path::{Path, PathBuf};
37
38use lean_rs_sys::lean_object;
39use object::{Object, ObjectSection, ObjectSymbol, SectionKind, SymbolSection};
40
41use super::initializer::{InitializerName, RawInitializer, call_initializer};
42use super::loaded::LeanModule;
43#[cfg(doc)]
44use crate::error::HostStage;
45use crate::error::{LeanError, LeanResult};
46use crate::runtime::LeanRuntime;
47
48/// A loaded native Lean shared object.
49///
50/// Wraps a [`libloading::Library`] and hands out initialized module
51/// handles via [`LeanLibrary::initialize_module`]. The `'lean` lifetime
52/// parameter ties the library to the witnessing
53/// [`crate::LeanRuntime`] borrow; the resulting [`LeanModule`] handles
54/// borrow from `self`, so they cannot outlive the library that hosts
55/// them.
56///
57/// Neither [`Send`] nor [`Sync`]: the `&'lean LeanRuntime` field
58/// inherits the runtime's per-thread restriction (`LeanRuntime: !Sync`
59/// implies `&LeanRuntime: !Send`, and `&LeanRuntime: Sync` iff
60/// `LeanRuntime: Sync`). A negative-bound compile-time assertion in
61/// the test module fails if either auto-trait is ever implemented.
62pub struct LeanLibrary<'lean> {
63    library: libloading::Library,
64    path: PathBuf,
65    runtime: &'lean LeanRuntime,
66    /// Names of data-section exports (Lean nullary-constant globals),
67    /// normalised to what [`libloading::Library::get`] resolves with
68    /// (Mach-O leading underscore stripped). Computed once at [`open`]
69    /// and consulted by
70    /// [`LeanModule::exported_unchecked`](super::loaded::LeanModule::exported_unchecked) to
71    /// dispatch function-vs-global at call time.
72    ///
73    /// [`open`]: Self::open
74    globals: HashSet<String>,
75}
76
77impl<'lean> LeanLibrary<'lean> {
78    /// Load a Lake-built Lean shared object from `path`.
79    ///
80    /// Reads the file's symbol table once to classify each exported
81    /// symbol as a function (text/code section) or a Lean
82    /// nullary-constant global (data/rodata/bss section). The
83    /// classification is consulted by
84    /// [`LeanModule::exported_unchecked`](super::loaded::LeanModule::exported_unchecked) so
85    /// callers never write the function-vs-global distinction at the
86    /// call site. The walk is cheap (a single `std::fs::read` plus an
87    /// in-memory parse) and amortised across every later lookup.
88    ///
89    /// The `runtime` borrow is the type-level proof that the Lean runtime
90    /// is up; it is retained for the symbol-initialization step but
91    /// otherwise unused. Module initialization happens later through
92    /// [`LeanLibrary::initialize_module`].
93    ///
94    /// # Errors
95    ///
96    /// Returns [`LeanError::Host`] with stage [`HostStage::Load`] if:
97    ///
98    /// - the file cannot be read (missing, permission denied),
99    /// - the bytes do not parse as a recognised object format (Mach-O,
100    ///   ELF, PE),
101    /// - the dynamic linker fails to open the file (missing transitive
102    ///   dependency, architecture mismatch, …).
103    ///
104    /// The diagnostic embeds the path and the underlying error message.
105    pub fn open(runtime: &'lean LeanRuntime, path: impl AsRef<Path>) -> LeanResult<Self> {
106        let path = path.as_ref();
107        let _span = tracing::debug_span!(
108            target: "lean_rs",
109            "lean_rs.module.library.open",
110            path = %crate::error::redact::short_path(path),
111        )
112        .entered();
113        let globals = classify_globals(path)?;
114        // SAFETY: `Library::new` runs the platform dynamic loader. Lake
115        // does not emit constructor-style initializers for Lean
116        // libraries (the per-module `initialize_*` functions are
117        // explicit, not C constructors), so the load is side-effect-free
118        // from Rust's perspective; the resulting handle releases the
119        // library on drop.
120        let library = unsafe { libloading::Library::new(path) }.map_err(|err| {
121            LeanError::module_init(format!("failed to open Lean library '{}': {err}", path.display()))
122        })?;
123        Ok(Self {
124            library,
125            path: path.to_path_buf(),
126            runtime,
127            globals,
128        })
129    }
130
131    /// Open a Lake-built Lean shared object with **globally visible
132    /// symbols** (POSIX `RTLD_GLOBAL` on Unix; the Windows side stays
133    /// on the default loader since DLL symbols are already global).
134    ///
135    /// The same contract as [`LeanLibrary::open`], plus: symbols
136    /// defined by this dylib become visible to the dynamic linker's
137    /// global namespace, so any subsequently `dlopen`ed dylib whose
138    /// initializer chain references them resolves correctly.
139    ///
140    /// The motivating case is the `lean-rs-host` shim load: the host shim
141    /// package imports the generic interop shim package, so opening the
142    /// generic dylib globally first lets the host shim initializer resolve
143    /// the generated `LeanRsInterop.*` references normally.
144    ///
145    /// # Errors
146    ///
147    /// Same as [`LeanLibrary::open`].
148    pub fn open_globally(runtime: &'lean LeanRuntime, path: impl AsRef<Path>) -> LeanResult<Self> {
149        let path = path.as_ref();
150        let _span = tracing::debug_span!(
151            target: "lean_rs",
152            "lean_rs.module.library.open_globally",
153            path = %crate::error::redact::short_path(path),
154        )
155        .entered();
156        let globals = classify_globals(path)?;
157        let library = open_with_global_visibility(path)?;
158        Ok(Self {
159            library,
160            path: path.to_path_buf(),
161            runtime,
162            globals,
163        })
164    }
165
166    /// Initialize the Lean module identified by `(package, module)`.
167    ///
168    /// Resolves the Lake-mangled initializer symbol against this
169    /// library, invokes it under a panic boundary with the runtime
170    /// `builtin` flag, and decodes the resulting `IO Unit`. Idempotent:
171    /// the Lean-emitted initializer body short-circuits to `IO.ok(())`
172    /// on its second and later calls, so repeated invocations are safe
173    /// and cheap.
174    ///
175    /// # Errors
176    ///
177    /// Returns [`LeanError::Host`] with:
178    ///
179    /// - [`HostStage::Link`] if `package` or `module` is not a valid
180    ///   Lake identifier, or the mangled initializer symbol is not
181    ///   exported by this library.
182    /// - [`HostStage::Load`] if the initializer panics or returns
183    ///   `IO.error`.
184    pub fn initialize_module<'lib>(&'lib self, package: &str, module: &str) -> LeanResult<LeanModule<'lean, 'lib>> {
185        let _span = tracing::debug_span!(
186            target: "lean_rs",
187            "lean_rs.module.library.initialize",
188            package = package,
189            module = module,
190        )
191        .entered();
192        let name = InitializerName::from_lake_names(package, module)?;
193        let init = self.lookup_initializer(&name)?;
194        call_initializer(self.runtime, init, &name)?;
195        Ok(LeanModule::new(self, name))
196    }
197
198    /// Names of data-section exports for this library, normalised to the
199    /// form [`libloading::Library::get`] resolves with.
200    pub(crate) fn globals(&self) -> &HashSet<String> {
201        &self.globals
202    }
203
204    /// The runtime borrow this library was opened with.
205    pub(crate) fn runtime(&self) -> &'lean LeanRuntime {
206        self.runtime
207    }
208
209    /// On-disk path the library was opened from. Used only for
210    /// diagnostics.
211    pub(crate) fn path(&self) -> &Path {
212        &self.path
213    }
214
215    /// Resolve `name` as a function-pointer symbol (text section).
216    ///
217    /// Private to `lean-rs`: callers reach this through
218    /// [`LeanModule::exported_unchecked`](super::loaded::LeanModule::exported_unchecked)
219    /// or manifest-checked [`LeanCapability::exported`](super::capability::LeanCapability::exported),
220    /// not by handling raw dynamic-loader addresses.
221    pub(crate) fn resolve_function_symbol(&self, name: &str) -> LeanResult<*mut c_void> {
222        // SAFETY: `libloading::Library::get::<*mut c_void>` is the raw
223        // address lookup; the returned `Symbol<'_, *mut c_void>` borrows
224        // from `self.library`, so dereferencing it inside this scope is
225        // valid. We copy the address out via `*symbol`—the same idiom
226        // `lookup_initializer` uses.
227        let symbol: libloading::Symbol<'_, *mut c_void> =
228            unsafe { self.library.get(name.as_bytes()) }.map_err(|err| {
229                LeanError::symbol_lookup(format!(
230                    "unknown exported symbol '{}' in '{}': {err}",
231                    name,
232                    self.path.display()
233                ))
234            })?;
235        Ok(*symbol)
236    }
237
238    /// Resolve `name` as a Lean nullary-constant global symbol
239    /// (data section). The returned pointer addresses the storage
240    /// holding the persistent `lean_object*` value; the caller reads
241    /// `*returned` to get the Lean object pointer itself.
242    ///
243    /// # Errors
244    ///
245    /// Returns [`LeanError::Host`] with stage [`HostStage::Link`] if
246    /// the symbol is not exported by this library.
247    pub(crate) fn resolve_global_symbol(&self, name: &str) -> LeanResult<*mut *mut lean_object> {
248        // SAFETY: `libloading::Library::get::<T>` returns the symbol's
249        // address typed as `T`. For data symbols the address is the
250        // location of the variable, so the parameterised type spells a
251        // pointer to the variable's value. The borrow lifetime ends
252        // when this function returns; we copy the address out.
253        let symbol: libloading::Symbol<'_, *mut *mut lean_object> = unsafe { self.library.get(name.as_bytes()) }
254            .map_err(|err| {
255                LeanError::symbol_lookup(format!(
256                    "unknown global symbol '{}' in '{}': {err}",
257                    name,
258                    self.path.display()
259                ))
260            })?;
261        Ok(*symbol)
262    }
263
264    /// Resolve the module initializer by `dlsym`, trying both the modern
265    /// (Lean ≥ 4.27) and legacy (Lean ≤ 4.26) Lake symbol shapes. The
266    /// diagnostic on failure names both candidates so the operator can
267    /// see what was searched.
268    fn lookup_initializer(&self, name: &InitializerName) -> LeanResult<RawInitializer> {
269        // SAFETY: the type parameter spells the canonical Lake
270        // initializer signature (`(u8) -> *mut lean_object`), verified
271        // against the Lake-emitted C in `fixtures/lean/.lake/build/ir/`
272        // for every supported Lean version. `libloading::Library::get`
273        // returns a borrowed `Symbol<'_, T>`; copying the raw function
274        // pointer out of it via deref is the standard pattern when the
275        // caller does not need to retain the borrow.
276        let modern: Result<libloading::Symbol<'_, RawInitializer>, _> =
277            unsafe { self.library.get(name.symbol_bytes()) };
278        if let Ok(symbol) = modern {
279            return Ok(*symbol);
280        }
281        // SAFETY: same as above; the legacy symbol shape is the only
282        // exported form for Lean ≤ 4.26 dylibs.
283        let legacy: Result<libloading::Symbol<'_, RawInitializer>, _> =
284            unsafe { self.library.get(name.legacy_symbol_bytes()) };
285        match legacy {
286            Ok(symbol) => Ok(*symbol),
287            Err(err) => Err(LeanError::linking(format!(
288                "missing initializer symbol in '{}': tried '{}' and '{}': {err}",
289                self.path.display(),
290                name.symbol_str(),
291                name.legacy_symbol_str(),
292            ))),
293        }
294    }
295}
296
297impl std::fmt::Debug for LeanLibrary<'_> {
298    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
299        f.debug_struct("LeanLibrary")
300            .field("path", &self.path)
301            .field("globals_count", &self.globals.len())
302            .finish()
303    }
304}
305
306/// Read `path` and collect the names of its data-section exports
307/// (Lean nullary-constant globals).
308///
309/// Mach-O export names carry a leading `_` that `libloading` strips at
310/// lookup; we strip here so the set keys match what
311/// [`LeanLibrary::resolve_global_symbol`] queries with. ELF and PE
312/// symbol names are stored without leading `_`, so no normalisation is
313/// needed on those platforms.
314///
315/// Symbols whose section can't be classified (undefined, absolute,
316/// common) are skipped: they cannot be the Lean-compiled persistent
317/// globals we care about.
318fn classify_globals(path: &Path) -> LeanResult<HashSet<String>> {
319    let bytes = std::fs::read(path)
320        .map_err(|err| LeanError::module_init(format!("failed to read Lean library '{}': {err}", path.display())))?;
321    let file = object::File::parse(&*bytes)
322        .map_err(|err| LeanError::module_init(format!("failed to parse object file '{}': {err}", path.display())))?;
323
324    let strip_underscore = matches!(file.format(), object::BinaryFormat::MachO | object::BinaryFormat::Wasm);
325
326    let mut globals = HashSet::new();
327    // `symbols()` covers both ELF `.symtab` (when present) and Mach-O
328    // `LC_SYMTAB` external defs; `dynamic_symbols()` on Mach-O omits
329    // data-section externals such as Lean nullary-constant globals,
330    // which is exactly what we need to classify.
331    for symbol in file.symbols() {
332        if !symbol.is_global() || symbol.is_undefined() {
333            continue;
334        }
335        let SymbolSection::Section(section_index) = symbol.section() else {
336            continue;
337        };
338        let Ok(section) = file.section_by_index(section_index) else {
339            continue;
340        };
341        if !is_data_section(section.kind()) {
342            continue;
343        }
344        let Ok(name) = symbol.name() else {
345            continue;
346        };
347        let normalised = if strip_underscore {
348            name.strip_prefix('_').unwrap_or(name)
349        } else {
350            name
351        };
352        if normalised.is_empty() {
353            continue;
354        }
355        globals.insert(normalised.to_owned());
356    }
357    Ok(globals)
358}
359
360/// Open `path` with the dynamic loader's *global* symbol-visibility
361/// flag set so any subsequently loaded dylib can resolve symbols
362/// defined here.
363///
364/// On Unix this means `RTLD_LAZY | RTLD_GLOBAL`. On Windows the
365/// platform loader publishes module symbols globally by default, so
366/// the standard [`libloading::Library::new`] path is sufficient.
367fn open_with_global_visibility(path: &Path) -> LeanResult<libloading::Library> {
368    #[cfg(unix)]
369    {
370        // SAFETY: identical contract to `Library::new`—runs the
371        // platform dynamic loader against `path`. The added
372        // `RTLD_GLOBAL` flag only affects symbol-table visibility for
373        // later loads; it does not change initializer-execution
374        // semantics (Lake-built Lean dylibs have no constructor-style
375        // init).
376        let unix_library = unsafe {
377            libloading::os::unix::Library::open(
378                Some(path),
379                libloading::os::unix::RTLD_LAZY | libloading::os::unix::RTLD_GLOBAL,
380            )
381        }
382        .map_err(|err| {
383            LeanError::module_init(format!(
384                "failed to open Lean library '{}' with RTLD_GLOBAL: {err}",
385                path.display()
386            ))
387        })?;
388        Ok(unix_library.into())
389    }
390    #[cfg(not(unix))]
391    {
392        // SAFETY: same as `Library::new`. See unix branch.
393        let library = unsafe { libloading::Library::new(path) }.map_err(|err| {
394            LeanError::module_init(format!("failed to open Lean library '{}': {err}", path.display()))
395        })?;
396        Ok(library)
397    }
398}
399
400/// Section kinds that hold runtime data (Lean nullary-constant
401/// `lean_object*` pointers live in `__data` on Mach-O, `.data` /
402/// `.rodata` / `.bss` on ELF).
403fn is_data_section(kind: SectionKind) -> bool {
404    matches!(
405        kind,
406        SectionKind::Data
407            | SectionKind::ReadOnlyData
408            | SectionKind::ReadOnlyDataWithRel
409            | SectionKind::UninitializedData
410            | SectionKind::Common
411            | SectionKind::Tls
412            | SectionKind::UninitializedTls
413    )
414}