@@ -18,12 +18,11 @@ use std::{
1818 path:: { Path , PathBuf } ,
1919 process:: Stdio ,
2020 sync:: { Arc , Mutex } ,
21- time:: SystemTime ,
2221} ;
2322
24- use anyhow:: { Context , Result , bail} ;
23+ use anyhow:: { Context , Result , bail, ensure } ;
2524use indicatif:: { ProgressBar , ProgressStyle } ;
26- use walkdir :: WalkDir ;
25+ use serde_json :: { Value , json } ;
2726
2827use crate :: { generate, resolve:: LockedRoots , scanner:: AnnealArtifact , setup:: Tool } ;
2928
@@ -271,8 +270,10 @@ pub fn run_aeneas(
271270
272271 // 4. Write Lakefile
273272 //
274- // Aeneas and its Lean dependencies are unpacked from the managed archive.
275- let path = materialize_aeneas_dependency ( & tmp_lean_root, & lean_root, & toolchain) ?;
273+ // Aeneas and its Lean dependencies are used directly from the managed
274+ // archive. The generated manifest below keeps Lake on the locked dependency
275+ // loading path, so package config/build caches can stay read-only.
276+ let path = toolchain. aeneas_lean_dir ( ) ;
276277 let aeneas_dep = format ! (
277278 r#"-- Aeneas rev: {}
278279require aeneas from "{}""# ,
@@ -307,21 +308,19 @@ lean_lib «User» where
307308 ) ;
308309 write_if_changed ( & tmp_lean_root. join ( "lakefile.lean" ) , & lakefile)
309310 . context ( "Failed to write Lakefile" ) ?;
311+ write_lake_manifest ( & tmp_lean_root, & toolchain) . context ( "Failed to write Lake manifest" ) ?;
310312
311313 // ATOMIC SWAP: If we successfully generated everything, we now swap the
312314 // temporary directory with the real one.
313315 let lean_root = roots. lean_root ( ) ;
314316 if lean_root. exists ( ) {
315- // Preserve the `.lake` directory and manifest to prevent re-downloading
316- // dependencies (like Mathlib) on subsequent runs.
317+ // Preserve the `.lake` directory for generated-workspace build/config
318+ // caches. The Lake manifest is regenerated in the temporary directory
319+ // above because it records absolute paths into the installed toolchain.
317320 let old_lake = lean_root. join ( ".lake" ) ;
318- let old_manifest = lean_root. join ( "lake-manifest.json" ) ;
319321 if old_lake. exists ( ) {
320322 fs:: rename ( & old_lake, tmp_lean_root. join ( ".lake" ) ) ?;
321323 }
322- if old_manifest. exists ( ) {
323- fs:: rename ( & old_manifest, tmp_lean_root. join ( "lake-manifest.json" ) ) ?;
324- }
325324
326325 // Remove the existing directory before renaming the temporary directory.
327326 // Note: `fs::rename` on Unix typically requires the target directory to be
@@ -338,169 +337,83 @@ lean_lib «User» where
338337 Ok ( ( ) )
339338}
340339
341- fn materialize_aeneas_dependency (
342- tmp_lean_root : & Path ,
343- lean_root : & Path ,
344- toolchain : & crate :: setup:: Toolchain ,
345- ) -> Result < PathBuf > {
346- let vendor_root = tmp_lean_root. join ( "vendor" ) . join ( "aeneas" ) ;
347- let lean_dst = vendor_root. join ( "backends" ) . join ( "lean" ) ;
348- let packages_dst = vendor_root. join ( "packages" ) ;
349-
350- println ! ( "Materializing packages by copying from toolchain..." ) ;
351- copy_toolchain_tree_writable ( & toolchain. aeneas_lean_dir ( ) , & lean_dst)
352- . context ( "Failed to copy Aeneas Lean package from toolchain" ) ?;
353- copy_toolchain_tree_writable ( & toolchain. aeneas_root ( ) . join ( "packages" ) , & packages_dst)
354- . context ( "Failed to copy Aeneas Lean dependencies from toolchain" ) ?;
355-
356- Ok ( lean_root. join ( "vendor" ) . join ( "aeneas" ) . join ( "backends" ) . join ( "lean" ) )
340+ fn write_lake_manifest ( lean_root : & Path , toolchain : & crate :: setup:: Toolchain ) -> Result < ( ) > {
341+ let manifest = generated_lake_manifest ( toolchain) ?;
342+ let mut contents =
343+ serde_json:: to_string_pretty ( & manifest) . context ( "Failed to serialize Lake manifest" ) ?;
344+ contents. push ( '\n' ) ;
345+ write_if_changed ( & lean_root. join ( "lake-manifest.json" ) , & contents)
357346}
358347
359- fn copy_toolchain_tree_writable ( src : & Path , dst : & Path ) -> Result < ( ) > {
360- if dst. exists ( ) {
361- fs:: remove_dir_all ( dst)
362- . with_context ( || format ! ( "Failed to remove stale directory {}" , dst. display( ) ) ) ?;
363- }
364-
365- let mut entries = WalkDir :: new ( src) . into_iter ( ) ;
366- while let Some ( entry) = entries. next ( ) {
367- let entry = entry. with_context ( || format ! ( "Failed to walk {}" , src. display( ) ) ) ?;
368- let path = entry. path ( ) ;
369- let rel = path. strip_prefix ( src) . with_context ( || {
370- format ! ( "Failed to relativize {} against {}" , path. display( ) , src. display( ) )
348+ fn generated_lake_manifest ( toolchain : & crate :: setup:: Toolchain ) -> Result < Value > {
349+ let aeneas_lean_dir = fs:: canonicalize ( toolchain. aeneas_lean_dir ( ) ) . with_context ( || {
350+ format ! (
351+ "Failed to resolve Aeneas Lake package directory {}" ,
352+ toolchain. aeneas_lean_dir( ) . display( )
353+ )
354+ } ) ?;
355+ let aeneas_manifest_path = aeneas_lean_dir. join ( "lake-manifest.json" ) ;
356+ let aeneas_manifest_file = fs:: File :: open ( & aeneas_manifest_path)
357+ . with_context ( || format ! ( "Failed to open {}" , aeneas_manifest_path. display( ) ) ) ?;
358+ let aeneas_manifest: Value = serde_json:: from_reader ( aeneas_manifest_file)
359+ . with_context ( || format ! ( "Failed to parse {}" , aeneas_manifest_path. display( ) ) ) ?;
360+ let aeneas_packages =
361+ aeneas_manifest. get ( "packages" ) . and_then ( Value :: as_array) . with_context ( || {
362+ format ! (
363+ "Aeneas Lake manifest {} is missing a packages array" ,
364+ aeneas_manifest_path. display( )
365+ )
371366 } ) ?;
372- let target = dst. join ( rel) ;
373-
374- if is_lake_build_dir ( rel) {
375- // The Nix-built archive already contains Lake build caches for the
376- // immutable Aeneas dependency tree. Link those caches back to the
377- // read-only toolchain instead of copying several GiB of `.olean`
378- // files into every generated Lean workspace. The surrounding
379- // package/config/source files are still copied below so Lake can
380- // update small mutable metadata such as `.lake/config`.
381- if let Some ( parent) = target. parent ( ) {
382- fs:: create_dir_all ( parent) . with_context ( || {
383- format ! ( "Failed to create parent directory {}" , parent. display( ) )
384- } ) ?;
385- }
386- create_symlink ( path, & target) . with_context ( || {
387- format ! (
388- "Failed to link prebuilt Lake cache {} to {}" ,
389- path. display( ) ,
390- target. display( )
391- )
392- } ) ?;
393- entries. skip_current_dir ( ) ;
394- } else if entry. file_type ( ) . is_dir ( ) {
395- fs:: create_dir_all ( & target)
396- . with_context ( || format ! ( "Failed to create directory {}" , target. display( ) ) ) ?;
397- } else if entry. file_type ( ) . is_symlink ( ) {
398- let link = fs:: read_link ( path)
399- . with_context ( || format ! ( "Failed to read symlink {}" , path. display( ) ) ) ?;
400- if let Some ( parent) = target. parent ( ) {
401- fs:: create_dir_all ( parent) . with_context ( || {
402- format ! ( "Failed to create parent directory {}" , parent. display( ) )
403- } ) ?;
404- }
405- create_symlink ( & link, & target)
406- . with_context ( || format ! ( "Failed to copy symlink {}" , target. display( ) ) ) ?;
407- } else if entry. file_type ( ) . is_file ( ) {
408- if let Some ( parent) = target. parent ( ) {
409- fs:: create_dir_all ( parent) . with_context ( || {
410- format ! ( "Failed to create parent directory {}" , parent. display( ) )
411- } ) ?;
412- }
413- fs:: copy ( path, & target) . with_context ( || {
414- format ! ( "Failed to copy {} to {}" , path. display( ) , target. display( ) )
415- } ) ?;
416- make_writable ( & target) ?;
417- }
418- }
419-
420- for entry in WalkDir :: new ( dst) . contents_first ( true ) {
421- let entry = entry. with_context ( || format ! ( "Failed to walk {}" , dst. display( ) ) ) ?;
422- if !entry. file_type ( ) . is_symlink ( ) {
423- make_writable ( entry. path ( ) ) ?;
424- }
425- }
426-
427- normalize_lake_input_mtimes ( dst) ?;
428367
429- Ok ( ( ) )
430- }
431-
432- fn is_lake_build_dir ( rel : & Path ) -> bool {
433- rel. file_name ( ) . is_some_and ( |name| name == "build" )
434- && rel. parent ( ) . and_then ( Path :: file_name) . is_some_and ( |name| name == ".lake" )
435- }
436-
437- #[ cfg( unix) ]
438- fn create_symlink ( src : & Path , dst : & Path ) -> std:: io:: Result < ( ) > {
439- std:: os:: unix:: fs:: symlink ( src, dst)
440- }
441-
442- #[ cfg( windows) ]
443- fn create_symlink ( src : & Path , dst : & Path ) -> std:: io:: Result < ( ) > {
444- if src. is_dir ( ) {
445- std:: os:: windows:: fs:: symlink_dir ( src, dst)
446- } else {
447- std:: os:: windows:: fs:: symlink_file ( src, dst)
448- }
449- }
450-
451- fn make_writable ( path : & Path ) -> Result < ( ) > {
452- let metadata =
453- fs:: symlink_metadata ( path) . with_context ( || format ! ( "Failed to stat {}" , path. display( ) ) ) ?;
454- if metadata. file_type ( ) . is_symlink ( ) {
455- return Ok ( ( ) ) ;
456- }
457- let mut perms = metadata. permissions ( ) ;
458- if perms. readonly ( ) {
459- #[ allow( clippy:: permissions_set_readonly_false) ]
460- perms. set_readonly ( false ) ;
461- fs:: set_permissions ( path, perms)
462- . with_context ( || format ! ( "Failed to make {} writable" , path. display( ) ) ) ?;
463- }
464- Ok ( ( ) )
465- }
466-
467- fn normalize_lake_input_mtimes ( root : & Path ) -> Result < ( ) > {
468- // The installed toolchain archive is intentionally read-only, and
469- // `copy_toolchain_tree_writable` symlinks `.lake/build` directories back to
470- // that archive so each verification workspace does not copy several GiB of
471- // prebuilt `.olean` files. That optimization only works if Lake accepts the
472- // linked cache artifacts as newer than the copied source/config files. A
473- // normal file copy gives the copied inputs fresh mtimes, so Lake `--old`
474- // can decide that archive outputs are stale and then fail while trying to
475- // remove read-only files through the `.lake/build` symlinks. Mirror the
476- // archive builder's convention: copied Lake inputs are older than the
477- // archive cache artifacts, while generated project files keep their normal
478- // mtimes and remain mutable.
479- for entry in WalkDir :: new ( root) {
480- let entry = entry. with_context ( || format ! ( "Failed to walk {}" , root. display( ) ) ) ?;
481- if !entry. file_type ( ) . is_file ( ) || !is_lake_input_file ( entry. path ( ) ) {
482- continue ;
483- }
484-
485- let file = fs:: File :: options ( ) . write ( true ) . open ( entry. path ( ) ) . with_context ( || {
486- format ! ( "Failed to open {} to normalize mtime" , entry. path( ) . display( ) )
368+ let mut packages = Vec :: with_capacity ( aeneas_packages. len ( ) + 1 ) ;
369+ packages. push ( json ! ( {
370+ "type" : "path" ,
371+ "name" : "aeneas" ,
372+ "dir" : path_to_manifest_string( & aeneas_lean_dir) ,
373+ "inherited" : false ,
374+ } ) ) ;
375+
376+ for entry in aeneas_packages {
377+ let mut entry = entry
378+ . as_object ( )
379+ . cloned ( )
380+ . context ( "Aeneas Lake manifest package entry is not an object" ) ?;
381+ let package_name = entry. get ( "name" ) . and_then ( Value :: as_str) . unwrap_or ( "<unknown>" ) ;
382+ let package_type = entry. get ( "type" ) . and_then ( Value :: as_str) . with_context ( || {
383+ format ! ( "Aeneas Lake manifest package entry {package_name} is missing type" )
487384 } ) ?;
488- file. set_times ( fs:: FileTimes :: new ( ) . set_modified ( SystemTime :: UNIX_EPOCH ) )
489- . with_context ( || format ! ( "Failed to normalize mtime for {}" , entry. path( ) . display( ) ) ) ?;
385+ ensure ! (
386+ package_type == "path" ,
387+ "Aeneas Lake manifest package entry {package_name} is {package_type:?}, not a path dependency"
388+ ) ;
389+ let package_dir = entry. get ( "dir" ) . and_then ( Value :: as_str) . with_context ( || {
390+ format ! ( "Aeneas Lake manifest package entry {package_name} is missing dir" )
391+ } ) ?;
392+ let package_dir = Path :: new ( package_dir) ;
393+ let package_dir = if package_dir. is_absolute ( ) {
394+ package_dir. to_path_buf ( )
395+ } else {
396+ aeneas_lean_dir. join ( package_dir)
397+ } ;
398+ let package_dir = fs:: canonicalize ( & package_dir)
399+ . with_context ( || format ! ( "Failed to resolve Lake package {}" , package_dir. display( ) ) ) ?;
400+ entry. insert ( "dir" . to_string ( ) , json ! ( path_to_manifest_string( & package_dir) ) ) ;
401+ entry. insert ( "inherited" . to_string ( ) , json ! ( true ) ) ;
402+ packages. push ( Value :: Object ( entry) ) ;
490403 }
491404
492- Ok ( ( ) )
405+ Ok ( json ! ( {
406+ "version" : "1.2.0" ,
407+ "packagesDir" : ".lake/packages" ,
408+ "packages" : packages,
409+ "name" : "anneal_verification" ,
410+ "lakeDir" : ".lake" ,
411+ "fixedToolchain" : false ,
412+ } ) )
493413}
494414
495- fn is_lake_input_file ( path : & Path ) -> bool {
496- if path. extension ( ) . and_then ( |ext| ext. to_str ( ) ) == Some ( "lean" ) {
497- return true ;
498- }
499-
500- matches ! (
501- path. file_name( ) . and_then( |name| name. to_str( ) ) ,
502- Some ( "lakefile.toml" | "lake-manifest.json" | "lean-toolchain" )
503- )
415+ fn path_to_manifest_string ( path : & Path ) -> String {
416+ path. to_string_lossy ( ) . into_owned ( )
504417}
505418
506419/// Generates Anneal `Specs.lean` and writes `Generated.lean`, but does not run the `lake build`.
@@ -760,11 +673,10 @@ fn configure_lake_command(
760673 // FIXME: Replace this with a cleaner toolchain/archive contract.
761674 //
762675 // The Nix-built archive contains prebuilt Lake outputs for the vendored
763- // Aeneas package, and `materialize_aeneas_dependency` links each generated
764- // verification workspace's `vendor/aeneas/backends/lean/.lake/build` back
765- // to that read-only archive cache. That is only sound if Lake evaluates the
766- // vendored Aeneas package with the same build configuration that was used
767- // when the archive was produced.
676+ // Aeneas package, and generated verification workspaces require that
677+ // package directly from the installed archive. That is only sound if Lake
678+ // evaluates Aeneas with the same build configuration that was used when the
679+ // archive was produced.
768680 //
769681 // Aeneas' Lakefile currently makes one of those build settings depend on
770682 // the ambient `CI` environment variable:
@@ -775,9 +687,8 @@ fn configure_lake_command(
775687 // sets `CI=true` for normal workflow steps. If we let that variable reach
776688 // this child process, Lake observes a different Aeneas package config than
777689 // the one recorded in the archive traces. It then invalidates the prebuilt
778- // cache and attempts to rebuild/remove files below the symlinked
779- // `.lake/build`, which fails because those archive files are intentionally
780- // read-only.
690+ // cache and attempts to rebuild/remove files below the installed archive's
691+ // read-only `.lake/build`.
781692 //
782693 // Scrubbing `CI` here keeps local runs, example CI jobs, and the integration
783694 // test harness aligned with the archive build environment. A cleaner future
@@ -1031,6 +942,71 @@ mod tests {
1031942 }
1032943 }
1033944
945+ #[ test]
946+ fn generated_lake_manifest_locks_archive_path_dependencies ( ) {
947+ let temp = tempfile:: tempdir ( ) . unwrap ( ) ;
948+ let toolchain_root = temp. path ( ) . join ( "toolchain" ) ;
949+ let aeneas_lean = toolchain_root. join ( "aeneas/backends/lean" ) ;
950+ let mathlib = toolchain_root. join ( "aeneas/packages/mathlib" ) ;
951+ let qq = toolchain_root. join ( "aeneas/packages/Qq" ) ;
952+ std:: fs:: create_dir_all ( & aeneas_lean) . unwrap ( ) ;
953+ std:: fs:: create_dir_all ( & mathlib) . unwrap ( ) ;
954+ std:: fs:: create_dir_all ( & qq) . unwrap ( ) ;
955+ std:: fs:: write (
956+ aeneas_lean. join ( "lake-manifest.json" ) ,
957+ serde_json:: to_string ( & json ! ( {
958+ "version" : "1.2.0" ,
959+ "packagesDir" : ".lake/packages" ,
960+ "packages" : [
961+ {
962+ "type" : "path" ,
963+ "name" : "mathlib" ,
964+ "dir" : "../../packages/mathlib" ,
965+ "inherited" : false ,
966+ } ,
967+ {
968+ "type" : "path" ,
969+ "name" : "Qq" ,
970+ "dir" : "../../packages/Qq" ,
971+ "inherited" : true ,
972+ "scope" : "" ,
973+ } ,
974+ ] ,
975+ "name" : "aeneas" ,
976+ "lakeDir" : ".lake" ,
977+ "fixedToolchain" : false ,
978+ } ) )
979+ . unwrap ( ) ,
980+ )
981+ . unwrap ( ) ;
982+
983+ let toolchain = crate :: setup:: Toolchain { root : toolchain_root } ;
984+ let manifest = generated_lake_manifest ( & toolchain) . unwrap ( ) ;
985+ let packages = manifest. get ( "packages" ) . unwrap ( ) . as_array ( ) . unwrap ( ) ;
986+
987+ assert_eq ! ( packages. len( ) , 3 ) ;
988+ assert_eq ! ( packages[ 0 ] [ "name" ] , "aeneas" ) ;
989+ assert_eq ! (
990+ packages[ 0 ] [ "dir" ] ,
991+ path_to_manifest_string( & std:: fs:: canonicalize( & aeneas_lean) . unwrap( ) )
992+ ) ;
993+ assert_eq ! ( packages[ 0 ] [ "inherited" ] , false ) ;
994+
995+ assert_eq ! ( packages[ 1 ] [ "name" ] , "mathlib" ) ;
996+ assert_eq ! (
997+ packages[ 1 ] [ "dir" ] ,
998+ path_to_manifest_string( & std:: fs:: canonicalize( & mathlib) . unwrap( ) )
999+ ) ;
1000+ assert_eq ! ( packages[ 1 ] [ "inherited" ] , true ) ;
1001+
1002+ assert_eq ! ( packages[ 2 ] [ "name" ] , "Qq" ) ;
1003+ assert_eq ! (
1004+ packages[ 2 ] [ "dir" ] ,
1005+ path_to_manifest_string( & std:: fs:: canonicalize( & qq) . unwrap( ) )
1006+ ) ;
1007+ assert_eq ! ( packages[ 2 ] [ "inherited" ] , true ) ;
1008+ }
1009+
10341010 fn mk_mapping (
10351011 lean_start : usize ,
10361012 lean_end : usize ,
0 commit comments