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}