1use std::collections::HashSet;
16
17use object::{Object, ObjectSymbol};
18
19use super::LeanBuiltCapability;
20use super::initializer::InitializerName;
21use crate::error::{LeanError, bound_message};
22
23pub(crate) use lean_toolchain::CapabilityManifest;
28pub use lean_toolchain::{LeanLoaderCheck, LeanLoaderDiagnosticCode, LeanLoaderReport, LeanLoaderSeverity};
29
30#[derive(Clone, Debug, Eq, PartialEq)]
35pub struct LeanRuntimePreflight {
36 spec: LeanBuiltCapability,
37}
38
39pub type LeanCapabilityPreflight = LeanRuntimePreflight;
44
45impl LeanRuntimePreflight {
46 #[must_use]
48 pub fn new(spec: LeanBuiltCapability) -> Self {
49 Self { spec }
50 }
51
52 #[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(¤t_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}