Skip to main content

lean_rs/module/
preflight.rs

1//! Manifest-backed loader preflight for shipped Lean capabilities.
2//!
3//! This module turns avoidable loader failures into stable, actionable
4//! diagnostics before callers hit platform-specific dynamic-loader text. It
5//! does not expose `ldd`, `otool`, `readelf`, `nm`, loader flags, or raw object
6//! parser details. The public contract is a report: what failed, which artifact
7//! it concerned, and what the caller should rebuild or package.
8//!
9//! The cheap, link-free static checks (file/JSON/schema/fingerprint/staleness)
10//! live in [`lean_toolchain::manifest_validation`] so the worker parent crate
11//! can fast-fail bad manifests without re-linking `libleanshared`. This module
12//! layers symbol-table inspection on top using the `object` crate, which is
13//! the half of preflight that genuinely needs the runtime crate.
14
15use std::collections::HashSet;
16
17use object::{Object, ObjectSymbol};
18
19use super::LeanBuiltCapability;
20use super::initializer::InitializerName;
21use crate::error::{LeanError, bound_message};
22
23// Loader data types live in `lean-toolchain` (below `lean-rs`) so the worker
24// wire protocol can reference them without re-linking `libleanshared`. The
25// runtime preflight in this module layers on top of the same data types.
26// Re-exported here for callers using the historical paths.
27pub(crate) use lean_toolchain::CapabilityManifest;
28pub use lean_toolchain::{LeanLoaderCheck, LeanLoaderDiagnosticCode, LeanLoaderReport, LeanLoaderSeverity};
29
30/// Runtime preflight runner for manifest-backed Lean capabilities.
31///
32/// Layers symbol-table inspection on top of
33/// [`lean_toolchain::manifest_validation::check_static`].
34#[derive(Clone, Debug, Eq, PartialEq)]
35pub struct LeanRuntimePreflight {
36    spec: LeanBuiltCapability,
37}
38
39/// Backwards-compatible alias for [`LeanRuntimePreflight`].
40///
41/// The struct was renamed when the static manifest-validation half moved to
42/// `lean-toolchain`; this alias preserves the historical public path.
43pub type LeanCapabilityPreflight = LeanRuntimePreflight;
44
45impl LeanRuntimePreflight {
46    /// Create a runtime preflight runner for a build-script capability descriptor.
47    #[must_use]
48    pub fn new(spec: LeanBuiltCapability) -> Self {
49        Self { spec }
50    }
51
52    /// Check manifest, artifact, toolchain, dependency, and initializer facts.
53    #[must_use]
54    pub fn check(&self) -> LeanLoaderReport {
55        let manifest_path = match self.spec.resolved_manifest_path() {
56            Ok(path) => path,
57            Err(err) => {
58                return LeanLoaderReport::new(
59                    None,
60                    vec![LeanLoaderCheck::error(
61                        LeanLoaderDiagnosticCode::MissingManifest,
62                        "manifest",
63                        err.to_string(),
64                        "rebuild the Lean capability through CargoLeanCapability and embed the manifest env var",
65                    )],
66                );
67            }
68        };
69
70        let manifest = match CapabilityManifest::read(&manifest_path) {
71            Ok(manifest) => manifest,
72            Err(check) => return LeanLoaderReport::new(Some(manifest_path), vec![check]),
73        };
74
75        let mut checks = Vec::new();
76        lean_toolchain::manifest_validation::check_fingerprint(&manifest, &mut checks);
77        lean_toolchain::manifest_validation::check_staleness(&manifest_path, &manifest, &mut checks);
78
79        let mut dependency_exports = HashSet::new();
80        for dependency in &manifest.dependencies {
81            match inspect_artifact(dependency.path_ref(), ArtifactRole::Dependency) {
82                Ok(info) => {
83                    dependency_exports.extend(info.defined_symbols);
84                }
85                Err(check) => checks.push(check),
86            }
87        }
88
89        match inspect_artifact(&manifest.primary_dylib, ArtifactRole::Primary) {
90            Ok(info) => {
91                check_initializer(&manifest, &info, &mut checks);
92                check_imported_symbols(&manifest, &info, &dependency_exports, &mut checks);
93            }
94            Err(check) => checks.push(check),
95        }
96
97        LeanLoaderReport::new(Some(manifest_path), checks)
98    }
99}
100
101pub(crate) fn report_into_error(report: LeanLoaderReport) -> LeanError {
102    let Some(first) = report
103        .into_checks()
104        .into_iter()
105        .find(|check| check.severity() == LeanLoaderSeverity::Error)
106    else {
107        return LeanError::module_init("Lean capability preflight failed without a recorded finding");
108    };
109    LeanError::module_init(bound_message(format!(
110        "{}: {}. repair: {}",
111        first.code().as_str(),
112        first.message(),
113        first.repair_hint()
114    )))
115}
116
117#[derive(Clone, Debug)]
118struct ArtifactInfo {
119    defined_symbols: HashSet<String>,
120    undefined_symbols: HashSet<String>,
121}
122
123#[derive(Copy, Clone, Debug, Eq, PartialEq)]
124enum ArtifactRole {
125    Primary,
126    Dependency,
127}
128
129fn inspect_artifact(path: &std::path::Path, role: ArtifactRole) -> Result<ArtifactInfo, LeanLoaderCheck> {
130    let missing_code = match role {
131        ArtifactRole::Primary => LeanLoaderDiagnosticCode::MissingPrimaryDylib,
132        ArtifactRole::Dependency => LeanLoaderDiagnosticCode::MissingTransitiveDependency,
133    };
134    let repair = match role {
135        ArtifactRole::Primary => {
136            "rebuild the Lean capability through CargoLeanCapability and package the primary dylib"
137        }
138        ArtifactRole::Dependency => {
139            "rebuild the Lean capability through CargoLeanCapability and package every manifest dependency"
140        }
141    };
142    let bytes = std::fs::read(path).map_err(|err| {
143        let code = if err.kind() == std::io::ErrorKind::NotFound {
144            missing_code
145        } else {
146            LeanLoaderDiagnosticCode::UnsupportedArchitecture
147        };
148        LeanLoaderCheck::error(
149            code,
150            path.display().to_string(),
151            format!("failed to read Lean dylib '{}': {err}", path.display()),
152            repair,
153        )
154    })?;
155    let file = object::File::parse(&*bytes).map_err(|err| {
156        LeanLoaderCheck::error(
157            LeanLoaderDiagnosticCode::UnsupportedArchitecture,
158            path.display().to_string(),
159            format!(
160                "Lean dylib '{}' is not a supported native object for this platform: {err}",
161                path.display()
162            ),
163            "rebuild the Lean capability for the current target architecture",
164        )
165    })?;
166    if !architecture_matches_host(file.architecture()) {
167        return Err(LeanLoaderCheck::error(
168            LeanLoaderDiagnosticCode::UnsupportedArchitecture,
169            path.display().to_string(),
170            format!(
171                "Lean dylib '{}' has architecture {:?}, but this process is {}",
172                path.display(),
173                file.architecture(),
174                std::env::consts::ARCH
175            ),
176            "rebuild the Lean capability for the current target architecture",
177        ));
178    }
179    let strip_underscore = matches!(file.format(), object::BinaryFormat::MachO | object::BinaryFormat::Wasm);
180    let mut defined_symbols = HashSet::new();
181    let mut undefined_symbols = HashSet::new();
182    for symbol in file.symbols().chain(file.dynamic_symbols()) {
183        let Ok(name) = symbol.name() else {
184            continue;
185        };
186        let normalised = normalise_symbol_name(name, strip_underscore);
187        if normalised.is_empty() {
188            continue;
189        }
190        if symbol.is_undefined() {
191            undefined_symbols.insert(normalised.to_owned());
192        } else if symbol.is_global() {
193            defined_symbols.insert(normalised.to_owned());
194        }
195    }
196    Ok(ArtifactInfo {
197        defined_symbols,
198        undefined_symbols,
199    })
200}
201
202fn check_initializer(manifest: &CapabilityManifest, primary: &ArtifactInfo, checks: &mut Vec<LeanLoaderCheck>) {
203    let initializer = match InitializerName::from_lake_names(&manifest.package, &manifest.module) {
204        Ok(initializer) => initializer,
205        Err(err) => {
206            checks.push(LeanLoaderCheck::error(
207                LeanLoaderDiagnosticCode::MalformedManifest,
208                format!("{}/{}", manifest.package, manifest.module),
209                err.to_string(),
210                "rebuild the manifest with valid Lake package and module names",
211            ));
212            return;
213        }
214    };
215    if primary.defined_symbols.contains(initializer.symbol_str())
216        || primary.defined_symbols.contains(initializer.legacy_symbol_str())
217    {
218        return;
219    }
220    checks.push(LeanLoaderCheck::error(
221        LeanLoaderDiagnosticCode::MissingInitializer,
222        format!("{}/{}", manifest.package, manifest.module),
223        format!(
224            "primary dylib '{}' does not export initializer '{}' or '{}'",
225            manifest.primary_dylib.display(),
226            initializer.symbol_str(),
227            initializer.legacy_symbol_str()
228        ),
229        "check the package/module names and rebuild the Lean capability shared target",
230    ));
231}
232
233fn check_imported_symbols(
234    manifest: &CapabilityManifest,
235    primary: &ArtifactInfo,
236    dependency_exports: &HashSet<String>,
237    checks: &mut Vec<LeanLoaderCheck>,
238) {
239    for symbol in primary
240        .undefined_symbols
241        .iter()
242        .filter(|symbol| is_lean_dependency_symbol(symbol))
243    {
244        if dependency_exports.contains(symbol) {
245            continue;
246        }
247        checks.push(LeanLoaderCheck::error(
248            LeanLoaderDiagnosticCode::MissingImportedSymbol,
249            symbol.clone(),
250            format!(
251                "primary dylib '{}' imports Lean symbol '{symbol}' that is not provided by the manifest dependencies",
252                manifest.primary_dylib.display()
253            ),
254            "rebuild the Lean capability through CargoLeanCapability so dependency dylibs are recorded in the manifest",
255        ));
256        return;
257    }
258}
259
260fn architecture_matches_host(architecture: object::Architecture) -> bool {
261    matches!(
262        (std::env::consts::ARCH, architecture),
263        ("x86_64", object::Architecture::X86_64)
264            | ("aarch64", object::Architecture::Aarch64)
265            | ("arm", object::Architecture::Arm)
266            | ("x86", object::Architecture::I386)
267    )
268}
269
270fn normalise_symbol_name(name: &str, strip_underscore: bool) -> &str {
271    if strip_underscore {
272        name.strip_prefix('_').unwrap_or(name)
273    } else {
274        name
275    }
276}
277
278fn is_lean_dependency_symbol(symbol: &str) -> bool {
279    symbol.starts_with("LeanRs") || symbol.starts_with("lean_rs_") || symbol.starts_with("initialize_LeanRs")
280}
281
282pub(crate) fn manifest_error_to_lean_error(check: LeanLoaderCheck) -> LeanError {
283    report_into_error(LeanLoaderReport::new(None, vec![check]))
284}
285
286#[cfg(test)]
287#[allow(clippy::expect_used, clippy::panic)]
288mod tests {
289    use super::{
290        ArtifactInfo, CapabilityManifest, LeanLoaderDiagnosticCode, LeanRuntimePreflight, check_imported_symbols,
291        inspect_artifact,
292    };
293    use crate::{LeanBuiltCapability, LeanCapability, LeanRuntime};
294    use std::collections::HashSet;
295    use std::fs;
296    use std::path::{Path, PathBuf};
297    use std::time::Duration;
298
299    #[test]
300    fn missing_manifest_reports_stable_code() {
301        let path = temp_dir("missing_manifest").join("missing.json");
302        let report = LeanRuntimePreflight::new(LeanBuiltCapability::manifest_path(path)).check();
303
304        assert!(!report.is_ok());
305        assert_eq!(
306            report.first_error().map(crate::LeanLoaderCheck::code),
307            Some(LeanLoaderDiagnosticCode::MissingManifest)
308        );
309    }
310
311    #[test]
312    fn malformed_manifest_reports_stable_code() {
313        let dir = temp_dir("malformed_manifest");
314        let manifest = dir.join("capability.json");
315        fs::write(&manifest, "{").expect("write malformed manifest");
316
317        let report = LeanRuntimePreflight::new(LeanBuiltCapability::manifest_path(&manifest)).check();
318
319        assert_eq!(
320            report.first_error().map(crate::LeanLoaderCheck::code),
321            Some(LeanLoaderDiagnosticCode::MalformedManifest)
322        );
323        assert_eq!(report.manifest_path(), Some(manifest.as_path()));
324    }
325
326    #[test]
327    fn unsupported_manifest_schema_reports_stable_code() {
328        let dir = temp_dir("unsupported_manifest_schema");
329        let manifest = dir.join("capability.json");
330        fs::write(
331            &manifest,
332            r#"{
333  "schema_version": 999,
334  "target_name": "Cap",
335  "package": "pkg",
336  "module": "Cap",
337  "primary_dylib": "/tmp/libcap.so"
338}"#,
339        )
340        .expect("write unsupported manifest schema");
341
342        let report = LeanRuntimePreflight::new(LeanBuiltCapability::manifest_path(manifest)).check();
343
344        assert_eq!(
345            report.first_error().map(crate::LeanLoaderCheck::code),
346            Some(LeanLoaderDiagnosticCode::UnsupportedManifestSchema)
347        );
348    }
349
350    #[test]
351    fn missing_primary_dylib_reports_stable_code() {
352        let dir = temp_dir("missing_primary_dylib");
353        let missing = dir.join("missing-capability.dylib");
354        let manifest = write_manifest(&dir, &missing, "", "");
355
356        let report = LeanRuntimePreflight::new(LeanBuiltCapability::manifest_path(manifest)).check();
357
358        assert!(contains_code(&report, LeanLoaderDiagnosticCode::MissingPrimaryDylib));
359    }
360
361    #[test]
362    fn missing_dependency_reports_stable_code() {
363        let dir = temp_dir("missing_dependency");
364        let missing = dir.join("missing-dependency.dylib");
365        let dependency = format!(
366            r#""dependencies":[{{"dylib_path":"{}","export_symbols_for_dependents":true}}],"#,
367            json_path(&missing)
368        );
369        let manifest = write_manifest(&dir, current_exe(), &dependency, "");
370
371        let report = LeanRuntimePreflight::new(LeanBuiltCapability::manifest_path(manifest)).check();
372
373        assert!(contains_code(
374            &report,
375            LeanLoaderDiagnosticCode::MissingTransitiveDependency
376        ));
377    }
378
379    #[test]
380    fn invalid_object_reports_unsupported_architecture() {
381        let dir = temp_dir("invalid_object");
382        let bad = dir.join("not-a-dylib.so");
383        fs::write(&bad, b"not an object").expect("write invalid object");
384
385        let err = inspect_artifact(&bad, super::ArtifactRole::Primary).expect_err("invalid object should fail");
386
387        assert_eq!(err.code(), LeanLoaderDiagnosticCode::UnsupportedArchitecture);
388    }
389
390    #[test]
391    fn unsupported_toolchain_fingerprint_reports_stable_code() {
392        let dir = temp_dir("unsupported_toolchain_fingerprint");
393        let manifest = dir.join("capability.json");
394        fs::write(
395            &manifest,
396            format!(
397                r#"{{
398  "schema_version": 1,
399  "target_name": "Cap",
400  "package": "pkg",
401  "module": "Cap",
402  "primary_dylib": "{}",
403  "toolchain_fingerprint": {{
404    "lean_version": "0.0.0",
405    "resolved_version": "0.0.0",
406    "header_sha256": "0000"
407  }}
408}}"#,
409                json_path(&current_exe())
410            ),
411        )
412        .expect("write unsupported fingerprint manifest");
413
414        let report = LeanRuntimePreflight::new(LeanBuiltCapability::manifest_path(manifest)).check();
415
416        assert!(contains_code(
417            &report,
418            LeanLoaderDiagnosticCode::UnsupportedToolchainFingerprint
419        ));
420    }
421
422    #[test]
423    fn stale_manifest_reports_stable_code() {
424        let dir = temp_dir("stale_manifest");
425        let primary = dir.join("libcap.so");
426        fs::write(&primary, b"old").expect("write old primary");
427        let manifest = write_manifest(&dir, &primary, "", "");
428        std::thread::sleep(Duration::from_millis(20));
429        fs::write(&primary, b"new").expect("write newer primary");
430        let parsed = CapabilityManifest::read(&manifest).expect("manifest parses");
431        let mut checks = Vec::new();
432
433        lean_toolchain::manifest_validation::check_staleness(&manifest, &parsed, &mut checks);
434
435        assert!(
436            checks
437                .iter()
438                .any(|check| check.code() == LeanLoaderDiagnosticCode::StaleManifest)
439        );
440    }
441
442    #[test]
443    fn missing_initializer_reports_stable_code() {
444        let dir = temp_dir("missing_initializer");
445        let manifest = write_manifest(&dir, current_exe(), "", "");
446
447        let report = LeanRuntimePreflight::new(LeanBuiltCapability::manifest_path(manifest)).check();
448
449        assert!(contains_code(&report, LeanLoaderDiagnosticCode::MissingInitializer));
450    }
451
452    #[test]
453    fn missing_imported_symbol_reports_stable_code() {
454        let primary = ArtifactInfo {
455            defined_symbols: HashSet::new(),
456            undefined_symbols: HashSet::from(["LeanRsInterop_missing".to_owned()]),
457        };
458        let manifest = CapabilityManifest {
459            primary_dylib: PathBuf::from("/tmp/libcap.so"),
460            package: "pkg".to_owned(),
461            module: "Cap".to_owned(),
462            dependencies: Vec::new(),
463            lean_version: None,
464            resolved_lean_version: None,
465            lean_header_sha256: None,
466        };
467        let mut checks = Vec::new();
468
469        check_imported_symbols(&manifest, &primary, &HashSet::new(), &mut checks);
470
471        assert!(
472            checks
473                .iter()
474                .any(|check| check.code() == LeanLoaderDiagnosticCode::MissingImportedSymbol)
475        );
476    }
477
478    #[test]
479    fn open_failure_uses_preflight_code_in_error_message() {
480        let dir = temp_dir("open_failure_preflight_code");
481        let missing = dir.join("missing-capability.dylib");
482        let manifest = write_manifest(&dir, &missing, "", "");
483        let runtime = LeanRuntime::init().expect("runtime init");
484
485        let Err(err) = LeanCapability::from_build_manifest(runtime, LeanBuiltCapability::manifest_path(manifest))
486        else {
487            panic!("missing primary should fail before open");
488        };
489
490        assert_eq!(err.code(), crate::LeanDiagnosticCode::ModuleInit);
491        assert!(
492            err.to_string()
493                .contains(LeanLoaderDiagnosticCode::MissingPrimaryDylib.as_str())
494        );
495    }
496
497    fn contains_code(report: &crate::LeanLoaderReport, code: LeanLoaderDiagnosticCode) -> bool {
498        report.checks().iter().any(|check| check.code() == code)
499    }
500
501    fn temp_dir(name: &str) -> PathBuf {
502        let dir = std::env::temp_dir().join(format!("lean-rs-preflight-{}-{name}", std::process::id()));
503        drop(fs::remove_dir_all(&dir));
504        fs::create_dir_all(&dir).expect("create preflight test dir");
505        dir
506    }
507
508    fn current_exe() -> PathBuf {
509        std::env::current_exe().expect("current test executable path")
510    }
511
512    fn write_manifest(dir: &Path, primary: impl AsRef<Path>, dependencies: &str, extra: &str) -> PathBuf {
513        let manifest = dir.join("capability.json");
514        let contents = format!(
515            r#"{{
516  {extra}
517  "schema_version": 1,
518  "target_name": "Cap",
519  "package": "pkg",
520  "module": "Cap",
521  "primary_dylib": "{}",
522  {dependencies}
523  "toolchain_fingerprint": {{
524    "lean_version": "{}",
525    "resolved_version": "{}",
526    "header_sha256": "{}"
527  }}
528}}"#,
529            json_path(primary.as_ref()),
530            lean_rs_sys::LEAN_VERSION,
531            lean_rs_sys::LEAN_RESOLVED_VERSION,
532            lean_rs_sys::LEAN_HEADER_DIGEST,
533        );
534        fs::write(&manifest, contents).expect("write manifest");
535        manifest
536    }
537
538    fn json_path(path: &Path) -> String {
539        path.display().to_string().replace('\\', "\\\\")
540    }
541}