Skip to content

Commit 0c88a83

Browse files
committed
[anneal] Check archive Lake cache reuse in tests
Findings: the symlink-free v1 contract depends on a fully read-only extracted Aeneas archive being usable by a fresh generated workspace with a complete relative manifest, lake --old, and no package reconfiguration or cache rebuild. In response, v1 and v2 integration tests now install the Nix-built archive, assert Aeneas is read-only, create a tiny generated Lake workspace with a relative locked manifest, then run lake --old build Generated and lake env lean --json against the archive to catch regressions. gherrit-pr-id: Gk6ir6ucgyyji67sqkhteqq3wjxijq7at
1 parent e8a9861 commit 0c88a83

5 files changed

Lines changed: 474 additions & 11 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
description = """
2+
Purpose: Verifies that the installed Nix-built archive can be used read-only by a fresh generated Lake workspace.
3+
Behavior: Builds and diagnoses a tiny workspace with a complete relative Lake manifest pointing at the installed Aeneas archive.
4+
"""

anneal/tests/integration.rs

Lines changed: 210 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use std::{
1212
use assert_cmd::assert::Assert;
1313
use fs2::FileExt as _;
1414
use serde::Deserialize;
15-
use serde_json::json;
15+
use serde_json::{Value, json};
1616
use walkdir::WalkDir;
1717

1818
fn new_sorted_walkdir(path: impl AsRef<Path>) -> WalkDir {
@@ -294,6 +294,210 @@ fn get_toolchain_bin_dir() -> PathBuf {
294294
get_toolchain_install_dir().join("aeneas").join("bin")
295295
}
296296

297+
fn run_archive_lake_cache_reuse_test(test_name: &str) -> datatest_stable::Result<()> {
298+
let _permit =
299+
profile_step(test_name, None, "wait_toolchain_run_slot", || acquire_toolchain_run_slot());
300+
let temp = tempfile::Builder::new()
301+
.prefix("anneal-archive-cache-reuse-")
302+
.tempdir_in(get_target_dir())?;
303+
assert_archive_lake_cache_reuse(test_name, &get_toolchain_install_dir(), temp.path())
304+
}
305+
306+
fn assert_archive_lake_cache_reuse(
307+
test_name: &str,
308+
toolchain_root: &Path,
309+
temp_root: &Path,
310+
) -> Result<(), Box<dyn std::error::Error>> {
311+
let aeneas_root = toolchain_root.join("aeneas");
312+
let aeneas_lean = aeneas_root.join("backends/lean");
313+
let lean_root = toolchain_root.join("lean");
314+
let workspace = temp_root.join("generated-workspace");
315+
316+
assert_no_write_bits(&aeneas_root)?;
317+
318+
fs::create_dir_all(workspace.join("generated"))?;
319+
fs::copy(aeneas_lean.join("lean-toolchain"), workspace.join("lean-toolchain"))?;
320+
fs::write(workspace.join("generated/Generated.lean"), "import Aeneas\n")?;
321+
fs::write(
322+
workspace.join("lakefile.lean"),
323+
format!(
324+
r#"import Lake
325+
open Lake DSL
326+
327+
require aeneas from "{}"
328+
329+
package anneal_verification
330+
331+
@[default_target]
332+
lean_lib Generated where
333+
srcDir := "generated"
334+
roots := #[`Generated]
335+
"#,
336+
lake_string(&aeneas_lean)
337+
),
338+
)?;
339+
write_relative_archive_manifest(&workspace, &aeneas_lean)?;
340+
341+
// This mirrors v1's generated workspace contract with the Nix-built
342+
// archive: dependency paths are locked relative to the workspace, package
343+
// caches are read-only, and `--old` must reuse the prebuilt Lake outputs.
344+
run_lake_archive_command(
345+
test_name,
346+
&workspace,
347+
&lean_root,
348+
&["--keep-toolchain", "--old", "build", "Generated"],
349+
)?;
350+
run_lake_archive_command(
351+
test_name,
352+
&workspace,
353+
&lean_root,
354+
&["--keep-toolchain", "env", "lean", "--json", "generated/Generated.lean"],
355+
)?;
356+
357+
Ok(())
358+
}
359+
360+
fn assert_no_write_bits(root: &Path) -> Result<(), Box<dyn std::error::Error>> {
361+
for entry in new_sorted_walkdir(root) {
362+
let entry = entry?;
363+
let metadata = fs::symlink_metadata(entry.path())?;
364+
if metadata.file_type().is_symlink() {
365+
continue;
366+
}
367+
if metadata.permissions().mode() & 0o222 != 0 {
368+
panic!("archive path should be read-only: {}", entry.path().display());
369+
}
370+
}
371+
Ok(())
372+
}
373+
374+
fn write_relative_archive_manifest(
375+
workspace: &Path,
376+
aeneas_lean: &Path,
377+
) -> Result<(), Box<dyn std::error::Error>> {
378+
let aeneas_lean = fs::canonicalize(aeneas_lean)?;
379+
let workspace = fs::canonicalize(workspace)?;
380+
let manifest_path = aeneas_lean.join("lake-manifest.json");
381+
let manifest: Value = serde_json::from_reader(fs::File::open(&manifest_path)?)?;
382+
let aeneas_packages = manifest.get("packages").and_then(Value::as_array).ok_or_else(|| {
383+
invalid_data(format!(
384+
"Aeneas Lake manifest {} is missing packages",
385+
manifest_path.display()
386+
))
387+
})?;
388+
389+
let aeneas_dir = relative_manifest_string(&aeneas_lean, &workspace)?;
390+
let mut packages = vec![json!({
391+
"type": "path",
392+
"name": "aeneas",
393+
"dir": aeneas_dir,
394+
"inherited": false,
395+
})];
396+
397+
for entry in aeneas_packages {
398+
let mut entry = entry
399+
.as_object()
400+
.cloned()
401+
.ok_or_else(|| invalid_data("Aeneas Lake manifest package entry is not an object"))?;
402+
let package_type = entry
403+
.get("type")
404+
.and_then(Value::as_str)
405+
.ok_or_else(|| invalid_data("Aeneas Lake manifest package entry is missing type"))?;
406+
if package_type != "path" {
407+
return Err(invalid_data(format!(
408+
"Aeneas Lake manifest package entry is {package_type:?}, not a path dependency"
409+
))
410+
.into());
411+
}
412+
let package_dir = entry
413+
.get("dir")
414+
.and_then(Value::as_str)
415+
.ok_or_else(|| invalid_data("Aeneas Lake manifest package entry is missing dir"))?;
416+
let package_dir = Path::new(package_dir);
417+
let package_dir = if package_dir.is_absolute() {
418+
package_dir.to_path_buf()
419+
} else {
420+
aeneas_lean.join(package_dir)
421+
};
422+
let package_dir = fs::canonicalize(package_dir)?;
423+
entry.insert("dir".to_string(), json!(relative_manifest_string(&package_dir, &workspace)?));
424+
entry.insert("inherited".to_string(), json!(true));
425+
packages.push(Value::Object(entry));
426+
}
427+
428+
let manifest = json!({
429+
"version": "1.2.0",
430+
"packagesDir": ".lake/packages",
431+
"packages": packages,
432+
"name": "anneal_verification",
433+
"lakeDir": ".lake",
434+
"fixedToolchain": false,
435+
});
436+
fs::write(
437+
workspace.join("lake-manifest.json"),
438+
format!("{}\n", serde_json::to_string_pretty(&manifest)?),
439+
)?;
440+
Ok(())
441+
}
442+
443+
fn relative_manifest_string(
444+
path: &Path,
445+
base: &Path,
446+
) -> Result<String, Box<dyn std::error::Error>> {
447+
let path = pathdiff::diff_paths(path, base).ok_or_else(|| {
448+
invalid_data(format!(
449+
"failed to compute relative path from {} to {}",
450+
base.display(),
451+
path.display()
452+
))
453+
})?;
454+
Ok(path.to_string_lossy().into_owned())
455+
}
456+
457+
fn lake_string(path: &Path) -> String {
458+
path.to_string_lossy().replace('\\', "\\\\").replace('"', "\\\"")
459+
}
460+
461+
fn run_lake_archive_command(
462+
test_name: &str,
463+
workspace: &Path,
464+
lean_root: &Path,
465+
args: &[&str],
466+
) -> Result<(), Box<dyn std::error::Error>> {
467+
let lean_bin = lean_root.join("bin");
468+
let mut cmd = process::Command::new(lean_bin.join("lake"));
469+
cmd.args(args)
470+
.current_dir(workspace)
471+
.env_remove("CI")
472+
.env("LEAN_SYSROOT", lean_root)
473+
.env("MATHLIB_NO_CACHE_ON_UPDATE", "1")
474+
.env("PATH", prepend_env_paths("PATH", &[lean_bin])?);
475+
476+
let lib_var = if cfg!(target_os = "macos") { "DYLD_LIBRARY_PATH" } else { "LD_LIBRARY_PATH" };
477+
cmd.env(
478+
lib_var,
479+
prepend_env_paths(lib_var, &[lean_root.join("lib"), lean_root.join("lib/lean")])?,
480+
);
481+
482+
run_command_with_profile(test_name, Some("archive_lake_cache_reuse"), cmd)?.assert.success();
483+
Ok(())
484+
}
485+
486+
fn prepend_env_paths(
487+
var_name: &str,
488+
new_paths: &[PathBuf],
489+
) -> Result<std::ffi::OsString, Box<dyn std::error::Error>> {
490+
let mut paths = new_paths.to_vec();
491+
if let Some(existing) = std::env::var_os(var_name) {
492+
paths.extend(std::env::split_paths(&existing));
493+
}
494+
Ok(std::env::join_paths(paths)?)
495+
}
496+
497+
fn invalid_data(message: impl Into<String>) -> io::Error {
498+
io::Error::new(io::ErrorKind::InvalidData, message.into())
499+
}
500+
297501
fn toolchain_run_jobs() -> usize {
298502
*TOOLCHAIN_RUN_JOBS.get_or_init(|| {
299503
std::env::var("ANNEAL_INTEGRATION_REAL_JOBS")
@@ -949,6 +1153,11 @@ fn run_integration_test(path: &Path) -> datatest_stable::Result<()> {
9491153
run_dirty_sandbox_test(path)
9501154
});
9511155
}
1156+
if path_str.contains("archive_lake_cache_reuse/anneal.toml") {
1157+
return profile_step(&test_name, None, "archive_lake_cache_reuse_test", || {
1158+
run_archive_lake_cache_reuse_test(&test_name)
1159+
});
1160+
}
9521161
// Load the test configuration from the associated 'anneal.toml' manifest.
9531162
let config = anneal_toml.test.unwrap_or_default();
9541163

anneal/v2/Cargo.lock

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

anneal/v2/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,8 @@ toml_const = "1.3.0"
5050
clap = { version = "4.5", features = ["derive"] }
5151
env_logger = "0.11"
5252
log = "0.4"
53+
54+
[dev-dependencies]
55+
pathdiff = "0.2"
56+
serde_json = "1"
57+
tempfile = "3"

0 commit comments

Comments
 (0)