diff --git a/CHANGELOG.md b/CHANGELOG.md index b44890d8b..e980cc50e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Rust SDK + +- The FPI macro diagnostic for a dependency package missing from a midenc-driven build now names + the searched `MIDENC_PACKAGE_CACHE` directory and the expected package file names, instead of + an empty candidate list and a `target/miden/` hint that the cache lookup never + consults #1302 +- The FPI dependency package lookup matches the `.masp` extension case-insensitively, aligning + the macro-side reader with the compiler's cache writers on case-insensitive filesystems #1302 +- FPI expansions record `option_env!("MIDENC_PACKAGE_CACHE")`, so a consumer crate recompiles — + and re-reads its dependency procedure roots — whenever the compiler's fingerprinted package + cache path rotates, even if a stale cache directory survives on disk #1302 + ## [0.10.0-rc.1] ### Compiler and `midenc` diff --git a/Cargo.lock b/Cargo.lock index 5808a0904..123087c10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3983,6 +3983,7 @@ dependencies = [ "parking_lot", "rustc-hash", "smallvec", + "tempfile", "termcolor", ] diff --git a/benches/src/lib.rs b/benches/src/lib.rs index 78e327874..7eced516b 100644 --- a/benches/src/lib.rs +++ b/benches/src/lib.rs @@ -191,12 +191,7 @@ impl BenchmarkRunner { .with_context(|| format!("failed to parse {}", inputs_path.display()))?; let mut executor = Executor::from_config(config); let packages_dir = project_dir.join("target/miden/packages"); - let mut dependencies = fs::read_dir(&packages_dir) - .with_context(|| format!("failed to read {}", packages_dir.display()))? - .map(|entry| entry.map(|entry| entry.path())) - .collect::>>()?; - dependencies.retain(|path| path.extension().is_some_and(|ext| ext == "masp")); - dependencies.sort(); + let dependencies = collect_dependency_packages(&packages_dir)?; for dependency in dependencies { executor .with_package(load_package(&dependency)?) @@ -214,6 +209,42 @@ impl BenchmarkRunner { } } +/// Collects the dependency packages that the executor must load for an example. +/// +/// The compiler stores dependency packages in the project package cache. Old compiler versions +/// write them directly into `target/miden/packages/`. New compiler versions write them into a +/// fingerprinted subdirectory of that cache. One runner binary drives both compiler versions +/// during a benchmark comparison, so the scan reads the cache directory and one level of +/// subdirectories. Package resolution at execution time is digest-addressed, so packages from a +/// stale cache entry are inert. +fn collect_dependency_packages(packages_dir: &Path) -> Result> { + let mut dependencies = Vec::new(); + for path in read_dir_paths(packages_dir)? { + if path.is_dir() { + dependencies + .extend(read_dir_paths(&path)?.into_iter().filter(|path| is_package_path(path))); + } else if is_package_path(&path) { + dependencies.push(path); + } + } + dependencies.sort(); + Ok(dependencies) +} + +/// Lists the entry paths of a directory. +fn read_dir_paths(dir: &Path) -> Result> { + fs::read_dir(dir) + .with_context(|| format!("failed to read {}", dir.display()))? + .map(|entry| entry.map(|entry| entry.path())) + .collect::>>() + .with_context(|| format!("failed to read {}", dir.display())) +} + +/// Returns true when a path names a serialized Miden package file. +fn is_package_path(path: &Path) -> bool { + path.extension().is_some_and(|ext| ext == "masp") +} + fn discover_cases(workspace_root: &Path) -> Result> { let examples_dir = workspace_root.join("examples"); let mut cases = Vec::new(); @@ -358,6 +389,22 @@ mod tests { ); } + #[test] + fn collects_packages_from_flat_and_fingerprinted_cache_layouts() { + let cache = tempfile::tempdir().unwrap(); + fs::write(cache.path().join("legacy.masp"), []).unwrap(); + fs::write(cache.path().join("1234567890abcdef.lock"), []).unwrap(); + let fingerprint = cache.path().join("1234567890abcdef"); + fs::create_dir_all(&fingerprint).unwrap(); + fs::write(fingerprint.join("miden-core.masp"), []).unwrap(); + fs::write(fingerprint.join("README.txt"), []).unwrap(); + + assert_eq!( + collect_dependency_packages(cache.path()).unwrap(), + vec![fingerprint.join("miden-core.masp"), cache.path().join("legacy.masp")] + ); + } + #[test] fn mast_size_excludes_package_metadata() { let mut package = Assembler::default() diff --git a/midenc-compile/src/pipeline/frontends/masm.rs b/midenc-compile/src/pipeline/frontends/masm.rs index 0ff4595d2..dd674320f 100644 --- a/midenc-compile/src/pipeline/frontends/masm.rs +++ b/midenc-compile/src/pipeline/frontends/masm.rs @@ -267,7 +267,8 @@ impl MasmProjectFrontend { /// `load_target_sources`, and so this frontend, only afterwards (`:428-434`). For an /// executable root, `assemble_interruptible` also assembles the project's library target /// before either (`:303-320`). So a lint-only run builds the dependency closure and writes - /// `.masp` files into `target/miden/packages` that it previously never produced. + /// `.masp` files into `target/miden/packages/` that it previously never + /// produced. /// /// That is an accepted consequence of routing project compilation through the assembler, /// not an oversight. The frontend is reachable only as an assembler callback, so any goal diff --git a/midenc-compile/src/pipeline/frontends/rust.rs b/midenc-compile/src/pipeline/frontends/rust.rs index 6fd858603..6d48e091b 100644 --- a/midenc-compile/src/pipeline/frontends/rust.rs +++ b/midenc-compile/src/pipeline/frontends/rust.rs @@ -1563,29 +1563,32 @@ pub(crate) mod manifest { let rustup_toolchain = crate::rust::rustup_toolchain(); let cargo_build_args = build_cargo_args(cargo_opts, compiler_opts.optimize); - // Enable memcopy and 128-bit arithmetic ops - let mut extra_rust_flags = String::from("-C target-feature=+bulk-memory,+wide-arithmetic"); - // Propagate the Miden VM target signal to the entire crate graph so Cargo can use it for - // cfg-based dependency selection. - extra_rust_flags.push_str(" --cfg miden"); - // Enable errors on missing stub functions - extra_rust_flags.push_str(" -C link-args=--fatal-warnings"); - // Remove the source file paths in the data segment for panics - // https://doc.rust-lang.org/beta/unstable-book/compiler-flags/location-detail.html - extra_rust_flags.push_str(" -Zlocation-detail=none"); - // Build with panic=immediate-abort - extra_rust_flags.push_str(" -Zunstable-options"); - extra_rust_flags.push_str(" -Cpanic=immediate-abort"); - if let Ok(inherited) = std::env::var("RUSTFLAGS") - && !inherited.is_empty() - { - extra_rust_flags.push(' '); - extra_rust_flags.push_str(&inherited); - } - if let Some(explicit) = compiler_opts.rustflags.as_deref() { - extra_rust_flags.push(' '); - extra_rust_flags.push_str(explicit); - } + let mandatory_rust_flags = [ + // Enable memcopy and 128-bit arithmetic ops + "-C", + "target-feature=+bulk-memory,+wide-arithmetic", + // Propagate the Miden VM target signal to the entire crate graph so Cargo can use it + // for cfg-based dependency selection. + "--cfg", + "miden", + // Enable errors on missing stub functions + "-C", + "link-args=--fatal-warnings", + // Remove the source file paths in the data segment for panics + // https://doc.rust-lang.org/beta/unstable-book/compiler-flags/location-detail.html + "-Zlocation-detail=none", + // Build with panic=immediate-abort + "-Zunstable-options", + "-Cpanic=immediate-abort", + ]; + let inherited_encoded = std::env::var_os("CARGO_ENCODED_RUSTFLAGS"); + let inherited_plain = std::env::var_os("RUSTFLAGS"); + let extra_rust_flags = merge_rust_flags( + &mandatory_rust_flags, + inherited_encoded.as_deref(), + inherited_plain.as_deref(), + compiler_opts.rustflags.as_deref(), + ); let wasi = if compiler_opts.target_requires_protocol() { "wasip2" @@ -1593,7 +1596,7 @@ pub(crate) mod manifest { "wasip1" }; - let env = cargo_env(filesystem_cache_dir, extra_rust_flags); + let env = cargo_env(filesystem_cache_dir, &extra_rust_flags); let mut wasm_outputs = run_cargo(wasi, rustup_toolchain.as_deref(), &cargo_build_args, env)?; @@ -1607,24 +1610,64 @@ pub(crate) mod manifest { /// /// `MIDENC_PACKAGE_CACHE` is the whole reason `filesystem_cache_dir` is threaded down from /// the entry point: it tells the nested `midenc` invocations where to publish the packages - /// they compile and where to look for the ones their own dependencies already produced. - /// Absent a cache directory the variable is *unset* rather than set to an empty path, which - /// is what makes the nested build fall back to its own default. + /// they compile and where to look for the ones their own dependencies already produced. The + /// directory is fingerprinted by the root build's compiler, options, and manifest closure so + /// every participant in that build uses the same isolated cache. Absent a cache directory the + /// variable is *unset* rather than set to an empty path, which is what makes the nested build + /// fall back to its own default. + /// + /// The composed rust flags are emitted twice: as `RUSTFLAGS` (space-joined, lossy for + /// arguments that contain spaces) and, authoritatively, as `CARGO_ENCODED_RUSTFLAGS` + /// (0x1f-joined). Cargo prefers the encoded variable, so emitting it explicitly is what keeps + /// the mandatory Miden flags — `--cfg miden`, the target features, the panic strategy — from + /// being replaced by an inherited value; the caller's own flags survive because + /// [`merge_rust_flags`] folds them into the composed list first. /// /// Named — rather than left inline where it was — so that this can be asserted without /// spawning `cargo -Z build-std` against the SDK. pub(super) fn cargo_env( filesystem_cache_dir: Option<&Path>, - extra_rust_flags: String, + extra_rust_flags: &[String], ) -> Vec<(&'static str, String)> { + let mut env = vec![ + ("RUSTFLAGS", extra_rust_flags.join(" ")), + ("CARGO_ENCODED_RUSTFLAGS", extra_rust_flags.join("\x1f")), + ]; if let Some(filesystem_cache_dir) = filesystem_cache_dir { - vec![ - ("RUSTFLAGS", extra_rust_flags), - ("MIDENC_PACKAGE_CACHE", filesystem_cache_dir.to_string_lossy().into_owned()), - ] - } else { - vec![("RUSTFLAGS", extra_rust_flags)] + env.push(("MIDENC_PACKAGE_CACHE", filesystem_cache_dir.to_string_lossy().into_owned())); + } + env + } + + /// Composes the rustc flag list for the nested cargo build. + /// + /// Inherited flags follow cargo's own precedence: a non-empty `CARGO_ENCODED_RUSTFLAGS` + /// (0x1f-separated; arguments may contain spaces) replaces plain `RUSTFLAGS` + /// (whitespace-split). Inherited flags are folded in after the mandatory set and before the + /// explicit `--rustflags`, preserving the pre-existing override order — nothing a caller + /// passes is dropped. + pub(super) fn merge_rust_flags( + mandatory: &[&str], + inherited_encoded: Option<&std::ffi::OsStr>, + inherited_plain: Option<&std::ffi::OsStr>, + explicit: Option<&str>, + ) -> Vec { + let mut args: Vec = mandatory.iter().map(|flag| flag.to_string()).collect(); + let encoded = inherited_encoded + .and_then(|value| value.to_str()) + .filter(|value| !value.is_empty()); + let plain = inherited_plain + .and_then(|value| value.to_str()) + .filter(|value| !value.is_empty()); + if let Some(encoded) = encoded { + args.extend(encoded.split('\x1f').filter(|arg| !arg.is_empty()).map(str::to_string)); + } else if let Some(plain) = plain { + args.extend(plain.split_whitespace().map(str::to_string)); + } + if let Some(explicit) = explicit { + args.extend(explicit.split_whitespace().map(str::to_string)); } + args } /// Returns the Cargo profile value for a compiler optimization level. @@ -3152,12 +3195,13 @@ path = "lib.rs" /// which is minutes of work and a network fetch. #[test] fn the_filesystem_cache_directory_is_handed_to_the_nested_cargo_build() { - let rustflags = String::from("-C target-feature=+bulk-memory"); + let rustflags = vec!["-C".to_string(), "target-feature=+bulk-memory".to_string()]; let cache = std::path::Path::new("/tmp/midenc-package-cache"); - let with = manifest::cargo_env(Some(cache), rustflags.clone()); + let with = manifest::cargo_env(Some(cache), &rustflags); assert!( - with.iter().any(|(key, value)| *key == "RUSTFLAGS" && *value == rustflags), + with.iter() + .any(|(key, value)| *key == "RUSTFLAGS" && *value == rustflags.join(" ")), "the rust flags must survive the cache directory being added: {with:?}" ); let (_, found) = with @@ -3166,17 +3210,86 @@ path = "lib.rs" .expect("a cache directory must be delivered to the nested build"); assert_eq!(found, &cache.display().to_string()); - let without = manifest::cargo_env(None, rustflags.clone()); + let without = manifest::cargo_env(None, &rustflags); assert!( !without.iter().any(|(key, _)| *key == "MIDENC_PACKAGE_CACHE"), "no cache directory means the variable is unset, not set to nothing: {without:?}" ); assert!( - without.iter().any(|(key, value)| *key == "RUSTFLAGS" && *value == rustflags), + without + .iter() + .any(|(key, value)| *key == "RUSTFLAGS" && *value == rustflags.join(" ")), "and the rust flags are handed over either way: {without:?}" ); } + #[test] + fn the_encoded_rustflags_override_inherited_values_with_the_same_flags() { + let rustflags = vec![ + "-C".to_string(), + "target-feature=+bulk-memory".to_string(), + "--cfg".to_string(), + "miden".to_string(), + ]; + + let env = manifest::cargo_env(None, &rustflags); + let (_, encoded) = env + .iter() + .find(|(key, _)| *key == "CARGO_ENCODED_RUSTFLAGS") + .expect("the encoded variable must be set so an inherited value cannot override it"); + // 0x1f-separated units, one per argument. + assert_eq!(*encoded, "-C\x1ftarget-feature=+bulk-memory\x1f--cfg\x1fmiden"); + } + + #[test] + fn merged_rust_flags_preserve_inherited_encoded_arguments() { + use std::ffi::OsStr; + + let mandatory = ["--cfg", "miden"]; + + // A non-empty encoded value wins over the plain spelling, mirroring cargo's precedence, + // and its 0x1f-separated arguments survive verbatim — including ones with spaces. + let merged = manifest::merge_rust_flags( + &mandatory, + Some(OsStr::new("-C\x1flink-args=--flag one --flag two")), + Some(OsStr::new("--cfg plain_ignored")), + Some("--cfg explicit"), + ); + assert_eq!( + merged, + vec![ + "--cfg".to_string(), + "miden".to_string(), + "-C".to_string(), + "link-args=--flag one --flag two".to_string(), + "--cfg".to_string(), + "explicit".to_string(), + ] + ); + + // Without an encoded value the plain spelling is whitespace-split, as cargo does. + let merged = + manifest::merge_rust_flags(&mandatory, None, Some(OsStr::new("-C opt-level=3")), None); + assert_eq!( + merged, + vec![ + "--cfg".to_string(), + "miden".to_string(), + "-C".to_string(), + "opt-level=3".to_string(), + ] + ); + + // Empty inherited values are treated as unset. + let merged = manifest::merge_rust_flags( + &mandatory, + Some(OsStr::new("")), + Some(OsStr::new("")), + None, + ); + assert_eq!(merged, vec!["--cfg".to_string(), "miden".to_string()]); + } + /// A WebAssembly module with a body, for the lowering half of the entry point. const MANIFEST_WAT: &str = r#" (module diff --git a/midenc-session/Cargo.toml b/midenc-session/Cargo.toml index d216a50a0..ce0c9c6ad 100644 --- a/midenc-session/Cargo.toml +++ b/midenc-session/Cargo.toml @@ -51,3 +51,6 @@ smallvec.workspace = true parking_lot = { workspace = true, optional = true } termcolor = { version = "1.4.1", optional = true } thiserror.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/midenc-session/src/lib.rs b/midenc-session/src/lib.rs index d333955fd..b4416da9c 100644 --- a/midenc-session/src/lib.rs +++ b/midenc-session/src/lib.rs @@ -27,6 +27,8 @@ mod inputs; mod libs; mod options; mod outputs; +#[cfg(any(test, feature = "std"))] +mod package_cache; pub mod path; pub mod registry; #[cfg(feature = "std")] @@ -79,6 +81,12 @@ pub struct Session { /// Statistics gathered from the current compiler session #[cfg(feature = "std")] pub statistics: Statistics, + /// The build-input fingerprint used to isolate this session's package cache. + /// + /// Memoization assumes fingerprint-relevant [`Options`] are not mutated after the first cache + /// path request. Cloning the session copies the memoized value when present. + #[cfg(feature = "std")] + package_cache_fingerprint: std::sync::OnceLock, } impl fmt::Debug for Session { @@ -311,6 +319,8 @@ impl Session { output_files, #[cfg(feature = "std")] statistics: Default::default(), + #[cfg(feature = "std")] + package_cache_fingerprint: Default::default(), } } @@ -352,7 +362,7 @@ impl Session { /// Get a new package registry instance for this session pub fn package_registry(&self) -> Result, Report> { - registry::HybridPackageRegistry::new_with_filesystem_cache( + registry::HybridPackageRegistry::new_with_derived_filesystem_cache( &self.options, self.filesystem_package_cache_dir(), ) @@ -361,11 +371,23 @@ impl Session { /// Where compiled dependency packages of this session's project are published and looked for. /// - /// `None` unless this session's input is a project locator: the cache lives under the - /// project's own `target/` directory, and a session compiling a standalone source file has no - /// project directory to put one under. Both readers — this session's package registry and the - /// nested `cargo` builds a Rust project's dependencies run through — must agree on the answer, - /// which is why there is one derivation of it. + /// `None` unless this session's input is a project locator: with `std`, the cache lives under + /// the project's own `target/miden/packages//` directory, and a session compiling + /// a standalone source file has no project directory to put one under. The fingerprint covers + /// the compiler identity, relevant build options, and the project's manifest closure. Both + /// readers — this session's package registry and the nested `cargo` builds a Rust project's + /// dependencies run through — must agree on the answer, which is why there is one derivation + /// of it. Without `std`, this returns the existing flat `target/miden/packages/` path without a + /// fingerprint component. + /// + /// Only the root compilation session derives this path. Nested dependency sessions receive + /// the root value threaded through their build environment, rather than deriving paths from + /// their own locators. The root is intentionally tied to the project directory and ignores + /// `--target-dir`, so every participant in one build agrees on the cache location. + /// + /// [`Session`] is [`Clone`]. Once this method has initialized the fingerprint, a clone keeps + /// that memoized value even if its public options are later changed; callers must mutate + /// fingerprint-relevant options before the first path request. /// /// Derived from the input locator rather than from a loaded manifest, which is what /// [`Session::new`] no longer has. That is also a repair: the manifest path was previously @@ -378,14 +400,42 @@ impl Session { return None; } let project_dir = input.as_path()?.parent()?; + let project_dir = if project_dir.is_absolute() { + project_dir.to_path_buf() + } else { + self.options.current_dir.join(project_dir) + }; // Canonicalized because the loaded manifest path this replaces was: the cache directory // is compared by path across nested builds, so `.`-relative and symlinked spellings of // one directory must not resolve to two caches. #[cfg(feature = "std")] - let project_dir = project_dir.canonicalize().unwrap_or_else(|_| project_dir.to_path_buf()); + let project_dir = project_dir.canonicalize().unwrap_or(project_dir); + #[cfg(feature = "std")] + let package_cache_dir = package_cache::package_cache_parent(&project_dir); + #[cfg(not(feature = "std"))] + let package_cache_dir = project_dir.join("target").join("miden").join("packages"); + #[cfg(feature = "std")] + { + let fingerprint = self.package_cache_fingerprint.get_or_init(|| { + let inherited_rustflags = std::env::var_os("RUSTFLAGS"); + let inherited_cargo_encoded_rustflags = std::env::var_os("CARGO_ENCODED_RUSTFLAGS"); + let inherited_rustup_toolchain = std::env::var_os("RUSTUP_TOOLCHAIN"); + package_cache::fingerprint( + &self.options, + &project_dir, + inherited_rustflags.as_deref(), + inherited_cargo_encoded_rustflags.as_deref(), + inherited_rustup_toolchain.as_deref(), + MIDENC_BUILD_VERSION, + MIDENC_BUILD_REV, + ) + }); + Some(package_cache_dir.join(fingerprint)) + } #[cfg(not(feature = "std"))] - let project_dir = project_dir.to_path_buf(); - Some(project_dir.join("target").join("miden").join("packages")) + { + Some(package_cache_dir) + } } /// Get the [OutputFile] to write the assembled MAST output to @@ -724,3 +774,40 @@ fn create_target_dir(path: &Path) { #[cfg(not(feature = "std"))] fn create_target_dir(_path: &Path) {} + +#[cfg(test)] +mod tests { + use alloc::sync::Arc; + + use tempfile::TempDir; + + use super::*; + + #[test] + fn relative_manifest_locator_uses_the_configured_current_directory() { + let temp = TempDir::new().unwrap(); + let options = Options { + current_dir: temp.path().to_path_buf(), + target_dir: temp.path().join("target"), + ..Options::default() + }; + let input = InputFile::new(FileType::Toml, InputType::Real("Cargo.toml".into())); + let session = Session::new_project( + "relative-manifest".into(), + Some(input), + Box::new(options), + None, + Arc::new(diagnostics::DefaultSourceManager::default()), + ); + + let cache_dir = session.filesystem_package_cache_dir().unwrap(); + let expected_parent = temp.path().canonicalize().unwrap().join("target/miden/packages"); + assert_eq!(cache_dir.parent(), Some(expected_parent.as_path())); + assert!( + package_cache::is_owned_filesystem_cache_path(&cache_dir), + "the derived cache path must satisfy the owned-layout check, or locking and pruning \ + silently degrade: {}", + cache_dir.display() + ); + } +} diff --git a/midenc-session/src/package_cache.rs b/midenc-session/src/package_cache.rs new file mode 100644 index 000000000..981992301 --- /dev/null +++ b/midenc-session/src/package_cache.rs @@ -0,0 +1,1470 @@ +//! Build-input fingerprints for the filesystem package cache. +//! +//! The fingerprint models inputs that change the *set and identity* of packages visible to a +//! build. Source files, lockfiles, `rust-toolchain.toml`, Cargo configuration files, and compiler +//! wrappers are deliberately excluded: they are content-only inputs, every resolved package is +//! rewritten into the current cache before its consumers expand, and the generated +//! `include_bytes!` reference makes Cargo re-expand when that package's contents change. +//! Same-path invalidation therefore inherits Cargo's file-freshness semantics: mtime-based unless +//! checksum freshness is enabled. +//! Expansions also record `MIDENC_PACKAGE_CACHE`, so rotating the fingerprinted path re-expands +//! consumers even if best-effort stale-directory pruning does not complete. +//! Concurrent builds with the same fingerprint serialize on the directory's exclusive builder +//! lock, so their publications and macro reads never interleave. Package publication still uses +//! a temporary file followed by atomic rename, so even a reader outside the protocol cannot see +//! a torn package. +//! Registry and git dependencies contribute declaration text only; in particular, a git branch +//! moving without a manifest edit is outside this fingerprint by design, as are a git package's +//! transitive dependencies. Pinning a revision or deleting the cache directory recovers from a +//! moved unpinned revision. The current fingerprint directory is never emptied before packages +//! are rewritten, so names dropped from the dependency set can linger until another fingerprinted +//! input rotates the directory. +//! +//! A workspace-root locator does not select a package, so `miden_project::Project::load` rejects +//! it. Such a locator contributes its raw manifests plus a load-failure marker and does not walk +//! workspace members; normal workspace compilation is expected to create per-member sessions. +//! Similarly, a Cargo-only project with no sibling `miden-project.toml` contributes both root +//! manifest slots and a load-failure marker, but its dependencies cannot be discovered and are +//! not recursed. That degraded case is reported at debug level while fingerprinting. +//! +//! This manifest walk cannot reuse `miden_project::ProjectDependencyGraphBuilder`: constructing +//! that resolver requires the `PackageRegistry` whose cache path is being derived, which is +//! circular, and its `build` operation may perform network git checkouts. Cache-path derivation +//! must remain local and available before the registry exists. +//! +//! The intended end state is content-addressed package storage, or immutable generation +//! directories derived from the fully resolved dependency graph. That belongs with the #1290 +//! package redesign and #1300 macro-side package pins; this fingerprint remains the conservative +//! build-input generation key until then. + +use alloc::{ + format, + string::{String, ToString}, + vec::Vec, +}; +use std::{ + collections::BTreeSet, + ffi::OsStr, + fs::{self, File, OpenOptions, TryLockError}, + path::{Path, PathBuf}, +}; + +use miden_core::crypto::hash::Blake3_256; +use miden_debug_types::{DefaultSourceManager, SourceManager}; +use miden_mast_package::Package; +use miden_project::{Dependency, DependencyVersionScheme, Project}; + +use crate::{DebugInfo, LinkLibrary, OptLevel, Options}; + +/// The number of lowercase hexadecimal characters in a package-cache fingerprint. +const FINGERPRINT_LEN: usize = 16; + +/// Returns true when `name` satisfies the package-cache fingerprint format. +fn is_fingerprint(name: &str) -> bool { + name.len() == FINGERPRINT_LEN + && name.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +/// The extension of a permanent sibling lock file for a fingerprint directory. +const BUILD_LOCK_EXTENSION: &str = "lock"; + +/// Prepares a filesystem package cache and returns its lifetime builder lock when available. +/// +/// Deletion is defense in depth. The primary invalidation is in the FPI expansion itself: it +/// records `option_env!("MIDENC_PACKAGE_CACHE")`, whose value carries the fingerprinted cache +/// path, so Cargo re-expands consumers whenever the fingerprint rotates. Pruning still removes +/// the `include_bytes!` targets of pre-fingerprint expansions, bounds stale package directories, +/// and takes dead caches out of circulation promptly. +/// +/// A build holds the exclusive `packages/.lock` lock for its registry's lifetime, +/// so identical-fingerprint builds serialize: the second waits for the first instead of sharing +/// the directory and rewriting the same package files with possibly divergent bytes. Pruning +/// takes the same lock non-blockingly while deleting the sibling directory. The permanent lock +/// lives outside that directory and is acquired before the directory is created, closing the +/// create-before-lock, unlock-before-delete, and unlink/recreate inode-ABA windows. +/// +/// `Some` means the exclusive liveness lock is held, even when a later preparation step +/// degraded. +/// `None` means locking was skipped: the path is unowned, or opening/locking the lock file +/// failed. Every leg keeps the cache configured and the cache directory created when possible, +/// so package publication proceeds against the expected path and reports any concrete +/// filesystem failure itself. +pub(crate) fn prepare_and_lock_filesystem_cache(filesystem_cache: &Path) -> Option { + if !is_owned_filesystem_cache_path(filesystem_cache) { + prepare_unowned_filesystem_cache(filesystem_cache); + return None; + } + + let parent = filesystem_cache + .parent() + .expect("an owned filesystem cache path always has a packages parent"); + if !create_filesystem_cache_parent(parent) { + return None; + } + let Some(filesystem_cache_lock) = acquire_filesystem_cache_lock(filesystem_cache) else { + // No lock could be held, but the build still runs against this path: create the + // directory now rather than leaving it to the first publication, so the failure mode + // is only "unprotected and unswept", not "missing". + create_current_filesystem_cache(filesystem_cache); + return None; + }; + if !create_current_filesystem_cache(filesystem_cache) { + // The exclusive lock is already held — keep it. A later publication may still recreate + // the directory (its writer creates parent directories), and the lock is what stops a + // concurrent pruner from classifying that recreated cache as dead. Only the sweep is + // skipped in this degraded mode. + return Some(filesystem_cache_lock); + } + sweep_stale_filesystem_cache_entries(filesystem_cache, parent); + Some(filesystem_cache_lock) +} + +/// Creates an unowned cache path without locking it or sweeping its parent. +fn prepare_unowned_filesystem_cache(filesystem_cache: &Path) { + if let Err(err) = fs::create_dir_all(filesystem_cache) { + log::warn!( + target: "package-registry", + "failed to create filesystem package cache '{}': {err}; keeping the cache configured so package publication reports the failure", + filesystem_cache.display() + ); + return; + } + log::debug!( + target: "package-registry", + "filesystem package cache '{}' is outside the owned miden/packages/ layout; skipping locking and parent pruning", + filesystem_cache.display() + ); +} + +/// Creates the owned cache parent before its permanent lock file is opened. +fn create_filesystem_cache_parent(parent: &Path) -> bool { + if let Err(err) = fs::create_dir_all(parent) { + log::warn!( + target: "package-registry", + "failed to create filesystem package cache parent '{}': {err}; keeping the cache configured so package publication reports the failure", + parent.display() + ); + return false; + } + true +} + +/// Creates or recreates the current cache directory after its builder lock is held. +fn create_current_filesystem_cache(filesystem_cache: &Path) -> bool { + if let Err(err) = fs::create_dir_all(filesystem_cache) { + log::warn!( + target: "package-registry", + "failed to create filesystem package cache '{}': {err}; keeping the cache configured so package publication reports the failure", + filesystem_cache.display() + ); + return false; + } + true +} + +/// Opens the current fingerprint's sibling lock file and holds the exclusive builder lock. +/// +/// Waiting is deadlock-free: pruners only try exclusive locks and never wait while holding one, +/// while a builder waits only for its own lock and holds no other lock. The wait is therefore +/// bounded by one in-progress stale-directory removal. +fn acquire_filesystem_cache_lock(filesystem_cache: &Path) -> Option { + let lock_path = filesystem_cache_lock_path(filesystem_cache); + let lock = match OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + { + Ok(lock) => lock, + Err(err) => { + log::warn!( + target: "package-registry", + "failed to open filesystem package cache liveness lock '{}': {err}; continuing without a liveness lock", + lock_path.display() + ); + return None; + } + }; + + if let Err(err) = lock.lock() { + log::warn!( + target: "package-registry", + "failed to lock filesystem package cache '{}': {err}; continuing without a liveness lock", + filesystem_cache.display() + ); + return None; + } + Some(lock) +} + +/// Removes dead fingerprint directories and legacy flat package files from `parent`. +fn sweep_stale_filesystem_cache_entries(filesystem_cache: &Path, parent: &Path) { + let entries = match fs::read_dir(parent) { + Ok(entries) => entries, + Err(err) => { + log::debug!( + target: "package-registry", + "failed to inspect filesystem package cache '{}': {err}", + parent.display() + ); + return; + } + }; + let current_lock_path = filesystem_cache_lock_path(filesystem_cache); + + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(err) => { + log::debug!( + target: "package-registry", + "failed to inspect an entry in filesystem package cache '{}': {err}", + parent.display() + ); + continue; + } + }; + let path = entry.path(); + if path == filesystem_cache || path == current_lock_path { + continue; + } + let file_type = match entry.file_type() { + Ok(file_type) => file_type, + Err(err) => { + log::debug!( + target: "package-registry", + "failed to inspect filesystem package cache entry '{}': {err}", + path.display() + ); + continue; + } + }; + + let is_stale_fingerprint = + file_type.is_dir() && is_package_cache_fingerprint(&entry.file_name()); + let is_legacy_package = file_type.is_file() + && path + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case(Package::EXTENSION)); + if is_stale_fingerprint { + prune_stale_fingerprint(&path, parent); + } else if is_legacy_package && let Err(err) = fs::remove_file(&path) { + warn_prune_failure(&path, parent, &err); + } + } +} + +/// Deletes a stale fingerprint directory while holding its exclusive permanent sibling lock. +fn prune_stale_fingerprint(fingerprint_dir: &Path, parent: &Path) { + let lock_path = filesystem_cache_lock_path(fingerprint_dir); + let lock = match OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + { + Ok(lock) => lock, + Err(err) => { + log::warn!( + target: "package-registry", + "cannot verify liveness of stale filesystem package cache '{}': {err}; skipping deletion", + fingerprint_dir.display() + ); + return; + } + }; + + match lock.try_lock() { + Ok(()) => { + if let Err(err) = fs::remove_dir_all(fingerprint_dir) { + warn_prune_failure(fingerprint_dir, parent, &err); + } + } + Err(TryLockError::WouldBlock) => { + log::debug!( + target: "package-registry", + "skipping live filesystem package cache '{}' during stale-cache pruning", + fingerprint_dir.display() + ) + } + Err(TryLockError::Error(err)) => { + log::warn!( + target: "package-registry", + "cannot verify liveness of stale filesystem package cache '{}': {err}; skipping deletion", + fingerprint_dir.display() + ) + } + } +} + +/// Logs a best-effort cleanup failure with the exact directory a user can remove. +fn warn_prune_failure(path: &Path, parent: &Path, err: &std::io::Error) { + log::warn!( + target: "package-registry", + "failed to prune stale filesystem package cache entry '{}': {err}; stale cache entries may survive; delete '{}' manually", + path.display(), + parent.display() + ); +} + +/// Returns the permanent sibling lock path associated with a fingerprint directory. +fn filesystem_cache_lock_path(filesystem_cache: &Path) -> PathBuf { + filesystem_cache.with_extension(BUILD_LOCK_EXTENSION) +} + +/// Returns true when a path is lexically owned by the `miden/packages/` layout. +/// Returns the package-cache parent directory for a project directory. +/// +/// This is the producer half of the owned-layout contract: the path it builds must satisfy +/// [`is_owned_filesystem_cache_path`] once a fingerprint component is appended, or the locking +/// and pruning protocol silently degrades to a debug log. `Session::filesystem_package_cache_dir` +/// derives through here, and its unit test asserts the coupling. +pub(crate) fn package_cache_parent(project_dir: &Path) -> PathBuf { + project_dir.join("target").join("miden").join("packages") +} + +pub(crate) fn is_owned_filesystem_cache_path(filesystem_cache: &Path) -> bool { + filesystem_cache.file_name().is_some_and(is_package_cache_fingerprint) + && filesystem_cache + .parent() + .and_then(Path::file_name) + .is_some_and(|name| name == OsStr::new("packages")) + && filesystem_cache + .parent() + .and_then(Path::parent) + .and_then(Path::file_name) + .is_some_and(|name| name == OsStr::new("miden")) +} + +/// Returns true when `name` has the cache fingerprint format owned by `midenc`. +fn is_package_cache_fingerprint(name: &OsStr) -> bool { + name.to_str().is_some_and(is_fingerprint) +} + +/// Computes the filesystem package cache fingerprint for a project build. +/// +/// Failures while reading or loading manifests are recorded as markers instead of being +/// returned. The normal project-loading path will diagnose those failures later with its full +/// context. A private source manager keeps fingerprinting from interning manifests in the +/// compilation session's source manager as a side effect. +pub(crate) fn fingerprint( + options: &Options, + project_dir: &Path, + inherited_rustflags: Option<&OsStr>, + inherited_cargo_encoded_rustflags: Option<&OsStr>, + inherited_rustup_toolchain: Option<&OsStr>, + compiler_version: &str, + compiler_revision: &str, +) -> String { + let mut transcript = Transcript::new(); + transcript.field("compiler.version", compiler_version.as_bytes()); + transcript.field("compiler.revision", compiler_revision.as_bytes()); + record_options( + &mut transcript, + options, + inherited_rustflags, + inherited_cargo_encoded_rustflags, + inherited_rustup_toolchain, + ); + + let source_manager = DefaultSourceManager::default(); + let mut manifests = ManifestClosure::new(&mut transcript, &source_manager); + manifests.visit_project(project_dir, None); + + let digest = Blake3_256::hash(transcript.as_bytes()); + let fingerprint = miden_core::utils::to_hex(&digest.as_bytes()[..FINGERPRINT_LEN / 2]); + log::debug!( + target: "package-cache", + "filesystem package cache fingerprint for '{}': {fingerprint}", + project_dir.display() + ); + fingerprint +} + +/// A length-prefixed, domain-separated byte transcript. +struct Transcript { + bytes: Vec, +} + +impl Transcript { + /// Creates an empty package-cache fingerprint transcript. + fn new() -> Self { + let mut transcript = Self { bytes: Vec::new() }; + transcript.field("domain", b"midenc-package-cache-v1"); + transcript + } + + /// Appends one named field to this transcript. + fn field(&mut self, name: &str, value: &[u8]) { + self.bytes.extend_from_slice(&(name.len() as u64).to_le_bytes()); + self.bytes.extend_from_slice(name.as_bytes()); + self.bytes.extend_from_slice(&(value.len() as u64).to_le_bytes()); + self.bytes.extend_from_slice(value); + } + + /// Appends an optional named field to this transcript. + fn optional_field(&mut self, name: &str, value: Option<&str>) { + self.optional_bytes_field(name, value.map(str::as_bytes)); + } + + /// Appends an optional named byte field to this transcript. + fn optional_bytes_field(&mut self, name: &str, value: Option<&[u8]>) { + match value { + Some(value) => { + self.field(&format!("{name}.state"), b"present"); + self.field(name, value); + } + None => self.field(&format!("{name}.state"), b"missing"), + } + } + + /// Returns the encoded transcript. + fn as_bytes(&self) -> &[u8] { + &self.bytes + } +} + +/// Records the build configuration which can affect package identity or selection. +fn record_options( + transcript: &mut Transcript, + options: &Options, + inherited_rustflags: Option<&OsStr>, + inherited_cargo_encoded_rustflags: Option<&OsStr>, + inherited_rustup_toolchain: Option<&OsStr>, +) { + let Options { + manifest_path: _, + name: _, + entrypoint: _, + profile, + workspace, + packages, + target, + target_type, + optimize, + debug, + output_types: _, + search_paths, + link_libraries, + link_modules: _, + sysroot, + midenup_home: _, + toolchain, + color: _, + diagnostics: _, + current_dir: _, + // The cache root is intentionally tied to the project directory rather than + // `--target-dir`, so every nested build participant derives the same location. + target_dir: _, + output_dir: _, + output_file: _, + remap_path_prefixes: _, + print_hir_source_locations: _, + stop_after: _, + parse_only: _, + analyze_only: _, + link_only: _, + no_link: _, + lint: _, + print_cfg_after_all: _, + print_cfg_after_pass: _, + print_ir_before_stage: _, + print_ir_after_all: _, + print_ir_after_pass: _, + print_ir_after_modified: _, + print_ir_filters: _, + save_temps: _, + rustflags, + cargo_frontmatter: _, + flags: _, + } = options; + + // Deliberate exclusions are classified here so adding an `Options` field forces a choice. + // Output paths, naming, diagnostics, printing, and stop flags do not select dependency + // packages. Link modules, remapped paths, custom flags, and similar content-affecting + // controls self-heal through the in-run package rewrite; the package-name set itself is + // driven by the manifest closure recorded below. Search paths ARE recorded: `-l` resolution + // scans them for the first stem match, so they select packages the same way the recorded + // sysroot and per-library paths do — and same-fingerprint builds must agree on selection, + // because they serialize on one directory and trust each other's files. + transcript.field("options.profile", profile.as_bytes()); + transcript.field("options.optimize", opt_level_name(*optimize).as_bytes()); + transcript.field("options.debug", debug_info_name(*debug).as_bytes()); + transcript.optional_field("options.target", target.as_deref()); + + let target_type = target_type.map(|target_type| target_type.to_string()); + transcript.optional_field("options.target_type", target_type.as_deref()); + + let mut packages = packages.clone(); + packages.sort(); + transcript.field("options.packages.count", &(packages.len() as u64).to_le_bytes()); + for package in packages { + transcript.field("options.package", package.as_bytes()); + } + + transcript.field("options.workspace", &[u8::from(*workspace)]); + transcript.optional_field("options.rustflags", rustflags.as_deref()); + transcript.optional_bytes_field( + "options.inherited_rustflags", + inherited_rustflags.map(OsStr::as_encoded_bytes), + ); + // Both inherited spellings are fingerprinted: the nested build merges them into its composed + // flag list (the encoded variable taking cargo's precedence over the plain one), so either + // one changes what gets built. + transcript.optional_bytes_field( + "options.inherited_cargo_encoded_rustflags", + inherited_cargo_encoded_rustflags.map(OsStr::as_encoded_bytes), + ); + transcript.optional_bytes_field( + "options.inherited_rustup_toolchain", + inherited_rustup_toolchain.map(OsStr::as_encoded_bytes), + ); + transcript.optional_field("options.toolchain", toolchain.as_deref()); + + let mut link_libraries = link_libraries.iter().map(link_library_input).collect::>(); + link_libraries.sort(); + transcript.field("options.link_libraries.count", &(link_libraries.len() as u64).to_le_bytes()); + for (name, path, linkage) in link_libraries { + transcript.field("options.link_library.name", name.as_bytes()); + transcript.optional_bytes_field("options.link_library.path", path.as_deref()); + transcript.field("options.link_library.linkage", linkage.as_bytes()); + } + + let mut search_paths = search_paths + .iter() + .map(|path| path.as_os_str().as_encoded_bytes().to_vec()) + .collect::>(); + search_paths.sort(); + transcript.field("options.search_paths.count", &(search_paths.len() as u64).to_le_bytes()); + for path in search_paths { + transcript.field("options.search_path", &path); + } + + transcript.optional_bytes_field( + "options.sysroot", + sysroot.as_deref().map(|path| path.as_os_str().as_encoded_bytes()), + ); +} + +/// Returns the stable transcript name for an optimization level. +fn opt_level_name(level: OptLevel) -> &'static str { + match level { + OptLevel::None => "none", + OptLevel::Basic => "basic", + OptLevel::Balanced => "balanced", + OptLevel::Max => "max", + OptLevel::Size => "size", + OptLevel::SizeMin => "size-min", + } +} + +/// Returns the stable transcript name for a debug-information level. +fn debug_info_name(level: DebugInfo) -> &'static str { + match level { + DebugInfo::None => "none", + DebugInfo::Line => "line", + DebugInfo::Full => "full", + } +} + +/// Returns the I/O-free identity of a requested link library. +/// +/// Built-in library versions are already pinned by the compiler build version. +fn link_library_input(library: &LinkLibrary) -> (String, Option>, &'static str) { + ( + library.name.to_string(), + library.path.as_deref().map(|path| path.as_os_str().as_encoded_bytes().to_vec()), + library.linkage.as_str(), + ) +} + +/// Walks and records the manifest closure of one project. +struct ManifestClosure<'a> { + transcript: &'a mut Transcript, + source_manager: &'a dyn SourceManager, + visited_projects: BTreeSet, + visited_packages: BTreeSet, + visited_workspace_roots: BTreeSet, +} + +impl<'a> ManifestClosure<'a> { + /// Creates an empty manifest-closure walk. + fn new(transcript: &'a mut Transcript, source_manager: &'a dyn SourceManager) -> Self { + Self { + transcript, + source_manager, + visited_projects: BTreeSet::new(), + visited_packages: BTreeSet::new(), + visited_workspace_roots: BTreeSet::new(), + } + } + + /// Records a project and recursively visits its local dependencies. + fn visit_project(&mut self, locator: &Path, expected_name: Option<&str>) { + let loaded = match expected_name { + Some(name) => Project::load_project_reference(name, locator, self.source_manager), + None => Project::load(locator, self.source_manager), + }; + let project_dir = loaded + .as_ref() + .ok() + .and_then(|project| project.package().manifest_path().map(Path::to_path_buf)) + .and_then(|manifest| manifest.parent().map(Path::to_path_buf)) + .unwrap_or_else(|| locator_project_dir(locator)); + let project_key = canonical_or_original(&project_dir); + if !self.visited_projects.insert(project_key) { + return; + } + + self.transcript.field("project", b"begin"); + let miden_manifest = project_dir.join("miden-project.toml"); + let cargo_manifest = project_dir.join("Cargo.toml"); + self.record_manifest(&miden_manifest); + self.record_manifest(&cargo_manifest); + if cargo_manifest.is_file() && !miden_manifest.is_file() { + log::debug!( + target: "package-cache", + "Cargo-only project '{}' has no sibling miden-project.toml; fingerprinting records its root manifests but cannot recurse dependencies", + project_dir.display() + ); + } + + let project = match loaded { + Ok(project) => project, + Err(err) => { + log::debug!( + target: "package-cache", + "failed to load project '{}' while fingerprinting its manifest closure: {err}", + locator.display() + ); + self.transcript.field("project.load", b"failed"); + self.transcript.field("project", b"end"); + return; + } + }; + self.transcript.field("project.load", b"succeeded"); + + let package = project.package(); + let workspace = match &project { + Project::WorkspacePackage { workspace, .. } => Some(workspace.as_ref()), + Project::Package(_) => None, + }; + let workspace_root = workspace.and_then(miden_project::Workspace::workspace_root); + if let Some(workspace_root) = workspace_root { + let workspace_key = canonical_or_original(workspace_root); + if self.visited_workspace_roots.insert(workspace_key) { + self.record_manifest(&workspace_root.join("miden-project.toml")); + self.record_manifest(&workspace_root.join("Cargo.toml")); + } + } + + let mut dependencies = package.dependencies().iter().collect::>(); + dependencies.sort_by_cached_key(|dependency| dependency_sort_key(dependency)); + self.transcript + .field("project.dependencies.count", &(dependencies.len() as u64).to_le_bytes()); + for dependency in dependencies { + self.visit_dependency(dependency, project_dir.as_path(), workspace); + } + self.transcript.field("project", b"end"); + } + + /// Records one project manifest, including an explicit marker when it cannot be read. + fn record_manifest(&mut self, path: &Path) { + let name = path.file_name().and_then(|name| name.to_str()).unwrap_or("manifest"); + self.transcript.field("manifest.name", name.as_bytes()); + match std::fs::read(path) { + Ok(bytes) => { + self.transcript.field("manifest.state", b"present"); + self.transcript.field("manifest.bytes", &bytes); + } + Err(err) => { + log::debug!( + target: "package-cache", + "unable to read manifest '{}' while fingerprinting: {err}; recording a missing marker", + path.display() + ); + self.transcript.field("manifest.state", b"missing"); + } + } + } + + /// Records one dependency and follows it when it names a local project or package file. + fn visit_dependency( + &mut self, + dependency: &Dependency, + manifest_dir: &Path, + workspace: Option<&miden_project::Workspace>, + ) { + // Keep scheme handling aligned with + // `frontend/masm/src/project.rs::collect_dependency_metadata_for_scheme`. The fingerprint + // walk intentionally differs from resolution in only two ways: path dependencies are + // extension-classified before canonicalization, so a symlink to a `.masp` is treated as + // source; and git declarations are recorded but their checkouts are never recursed. + self.transcript.field("dependency", b"begin"); + self.transcript.field("dependency.name", dependency.name().as_bytes()); + self.transcript + .field("dependency.scheme", dependency_scheme_key(dependency).as_bytes()); + + match dependency.scheme() { + DependencyVersionScheme::Registry(_) => {} + DependencyVersionScheme::Path { path, .. } => { + // A path dependency naming a workspace member (by directory or by manifest + // file, both accepted spellings) is workspace-root-relative, not + // manifest-dir-relative — mirror the classifier and resolve it through the + // member list first. + if let Some(member_manifest) = workspace_member_manifest(workspace, path.inner()) { + self.visit_project(&member_manifest, Some(dependency.name().as_ref())); + } else { + self.visit_path_dependency(dependency, manifest_dir, path.inner()); + } + } + DependencyVersionScheme::WorkspacePath { path, .. } => { + if let Some(workspace_root) = + workspace.and_then(miden_project::Workspace::workspace_root) + { + self.visit_path_dependency(dependency, workspace_root, path.inner()); + } else { + log::debug!( + target: "package-cache", + "cannot resolve workspace path dependency '{}' while fingerprinting outside a workspace", + dependency.name() + ); + self.transcript.field("dependency.path", b"unresolved-workspace"); + } + } + DependencyVersionScheme::Workspace { member, .. } => { + if let Some(manifest_path) = workspace_member_manifest(workspace, member.inner()) { + self.visit_project(&manifest_path, Some(dependency.name().as_ref())); + } else { + log::debug!( + target: "package-cache", + "cannot resolve workspace member dependency '{}' at '{}' while fingerprinting", + dependency.name(), + member.inner().path() + ); + self.transcript.field("dependency.path", b"unresolved-workspace"); + } + } + DependencyVersionScheme::Git { repo, revision, .. } => { + self.transcript.field("dependency.git.repo", repo.inner().as_bytes()); + self.transcript + .field("dependency.git.revision", revision.inner().to_string().as_bytes()); + } + } + self.transcript.field("dependency", b"end"); + } + + /// Resolves and records a filesystem dependency using the manifest scheme's base directory. + fn visit_path_dependency( + &mut self, + dependency: &Dependency, + base_dir: &Path, + uri: &miden_project::Uri, + ) { + if uri.scheme().is_some_and(|scheme| scheme != "file") { + log::debug!( + target: "package-cache", + "unsupported URI '{}' for path dependency '{}' while fingerprinting", + uri.as_str(), + dependency.name() + ); + self.transcript.field("dependency.path", b"unsupported-uri"); + return; + } + + let relative = Path::new(uri.path()); + let path = if relative.is_absolute() { + relative.to_path_buf() + } else { + base_dir.join(relative) + }; + if path + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case(Package::EXTENSION)) + { + self.record_package_file(&path); + } else { + self.visit_project(&path, Some(dependency.name().as_ref())); + } + } + + /// Records the content hash of a preassembled package dependency. + fn record_package_file(&mut self, path: &Path) { + let key = canonical_or_original(path); + if !self.visited_packages.insert(key) { + return; + } + + self.transcript.field("package.file", b"begin"); + match std::fs::read(path) { + Ok(bytes) => { + self.transcript.field("package.file.state", b"present"); + let digest = Blake3_256::hash(&bytes); + self.transcript.field("package.file.digest", digest.as_bytes()); + } + Err(err) => { + log::debug!( + target: "package-cache", + "unable to read preassembled package '{}' while fingerprinting: {err}; recording a missing marker", + path.display() + ); + self.transcript.field("package.file.state", b"missing"); + } + } + self.transcript.field("package.file", b"end"); + } +} + +/// Returns a deterministic key for dependency traversal order. +fn dependency_sort_key(dependency: &Dependency) -> (String, String) { + (dependency.name().to_string(), dependency_scheme_key(dependency)) +} + +/// Returns a stable textual projection of a dependency's resolved scheme. +fn dependency_scheme_key(dependency: &Dependency) -> String { + match dependency.scheme() { + DependencyVersionScheme::Registry(requirement) => format!("registry:{requirement}"), + DependencyVersionScheme::Path { path, version } => { + format!("path:{}:{}", path.inner().as_str(), optional_display(version.as_ref())) + } + DependencyVersionScheme::WorkspacePath { path, version } => format!( + "workspace-path:{}:{}", + path.inner().as_str(), + optional_display(version.as_ref()) + ), + DependencyVersionScheme::Workspace { member, version } => { + format!("workspace:{}:{}", member.inner().as_str(), optional_display(version.as_ref())) + } + DependencyVersionScheme::Git { + repo, + revision, + version, + } => format!( + "git:{}:{}:{}", + repo.inner().as_str(), + revision.inner(), + optional_display(version.as_ref().map(|version| version.inner())) + ), + } +} + +/// Formats an optional display value without conflating absence with an empty value. +fn optional_display(value: Option<&impl core::fmt::Display>) -> String { + value.map(ToString::to_string).unwrap_or_else(|| "".into()) +} + +/// Resolves a dependency URI to a workspace member's manifest path, when it names one. +/// +/// Members may be declared by their directory (`dep`) or by their manifest file +/// (`dep/miden-project.toml`) — both spellings are accepted by `miden-project`'s classifier and +/// are workspace-root-relative. The workspace lookup compares member directories, so the +/// manifest-file spelling is normalized to its parent first. +fn workspace_member_manifest( + workspace: Option<&miden_project::Workspace>, + uri: &miden_project::Uri, +) -> Option { + if uri.scheme().is_some_and(|scheme| scheme != "file") { + return None; + } + let member_dir = locator_project_dir(Path::new(uri.path())); + workspace? + .get_member_by_relative_path(member_dir.as_path()) + .and_then(|package| package.manifest_path().map(Path::to_path_buf)) +} + +/// Returns the directory whose sibling project manifests describe a locator. +fn locator_project_dir(locator: &Path) -> PathBuf { + if locator.file_name().is_some_and(|name| { + name.eq_ignore_ascii_case("miden-project.toml") || name.eq_ignore_ascii_case("Cargo.toml") + }) { + locator.parent().map(Path::to_path_buf).unwrap_or_else(|| locator.to_path_buf()) + } else { + locator.to_path_buf() + } +} + +/// Canonicalizes a path for cycle detection, retaining the original spelling on failure. +fn canonical_or_original(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) +} + +#[cfg(test)] +mod tests { + use std::{fs::OpenOptions, sync::mpsc, time::Duration}; + + use tempfile::TempDir; + + use super::*; + + #[test] + fn creating_a_filesystem_cache_prunes_only_stale_owned_entries() { + let temp = TempDir::new().unwrap(); + let parent = temp.path().join("miden").join("packages"); + let current = parent.join("fedcba9876543210"); + let stale = parent.join("0123456789abcdef"); + let unrelated_directory = parent.join("not-a-midenc-cache"); + let uppercase_directory = parent.join("ABCDEF0123456789"); + let legacy_package = parent.join("legacy.masp"); + let uppercase_legacy_package = parent.join("uppercase.MASP"); + let permanent_orphan_lock = parent.join("1111111111111111.lock"); + let live_precreation_lock_path = parent.join("2222222222222222.lock"); + let unrelated_file = parent.join("keep.txt"); + + for directory in [¤t, &stale, &unrelated_directory, &uppercase_directory] { + fs::create_dir_all(directory).unwrap(); + } + let current_marker = current.join("keep"); + fs::write(¤t_marker, b"current").unwrap(); + fs::write(stale.join("old.masp"), b"stale").unwrap(); + fs::write(&legacy_package, b"legacy").unwrap(); + fs::write(&uppercase_legacy_package, b"legacy").unwrap(); + fs::write(&permanent_orphan_lock, b"").unwrap(); + let live_precreation_lock = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&live_precreation_lock_path) + .unwrap(); + live_precreation_lock.try_lock().unwrap(); + fs::write(&unrelated_file, b"unrelated").unwrap(); + + let current_lock = + prepare_and_lock_filesystem_cache(¤t).expect("current cache must be locked"); + + assert!(current_marker.exists(), "the current cache must remain intact"); + assert!(!stale.exists(), "a stale fingerprint directory must be removed"); + assert!( + filesystem_cache_lock_path(&stale).exists(), + "the stale fingerprint's rendezvous lock must remain permanent" + ); + assert!(!legacy_package.exists(), "a legacy flat package must be removed"); + assert!( + !uppercase_legacy_package.exists(), + "legacy package extensions must be matched case-insensitively" + ); + assert!( + permanent_orphan_lock.exists(), + "an orphan fingerprint lock is a permanent rendezvous object" + ); + assert!( + live_precreation_lock_path.exists(), + "a lock held before its directory is created must remain permanent" + ); + assert!(unrelated_directory.exists(), "unowned directories must be retained"); + assert!(uppercase_directory.exists(), "non-lowercase directories must be retained"); + assert!(unrelated_file.exists(), "unowned files must be retained"); + + drop(live_precreation_lock); + drop(current_lock); + } + + #[test] + fn builder_waits_for_an_in_progress_prune_and_recreates_its_cache() { + let temp = TempDir::new().unwrap(); + let current = temp.path().join("miden").join("packages").join("fedcba9876543210"); + fs::create_dir_all(¤t).unwrap(); + let lock_path = filesystem_cache_lock_path(¤t); + let exclusive_lock = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + .unwrap(); + exclusive_lock.try_lock().unwrap(); + fs::remove_dir_all(¤t).unwrap(); + + let (started_tx, started_rx) = mpsc::channel(); + let (completed_tx, completed_rx) = mpsc::channel(); + let thread_cache = current.clone(); + let builder = std::thread::spawn(move || { + started_tx.send(()).unwrap(); + completed_tx.send(prepare_and_lock_filesystem_cache(&thread_cache)).unwrap(); + }); + + started_rx.recv_timeout(Duration::from_secs(1)).unwrap(); + assert!( + completed_rx.recv_timeout(Duration::from_millis(100)).is_err(), + "the builder must wait while the pruner holds the exclusive lock" + ); + drop(exclusive_lock); + + let builder_lock = completed_rx + .recv_timeout(Duration::from_secs(5)) + .unwrap() + .expect("the builder must acquire the lock after pruning completes"); + builder.join().unwrap(); + assert!(current.is_dir(), "the builder must recreate the pruned cache directory"); + let exclusive_contender = + OpenOptions::new().read(true).write(true).open(lock_path).unwrap(); + assert!(matches!(exclusive_contender.try_lock(), Err(TryLockError::WouldBlock))); + drop(builder_lock); + } + + #[test] + fn cache_create_failure_keeps_the_acquired_liveness_lock_and_skips_the_sweep() { + let temp = TempDir::new().unwrap(); + let parent = temp.path().join("miden").join("packages"); + let current = parent.join("fedcba9876543210"); + let stale = parent.join("0123456789abcdef"); + std::fs::create_dir_all(&stale).unwrap(); + // A regular file at the fingerprint path makes `create_dir_all` fail after the shared + // lock is already held. + std::fs::write(¤t, b"not a directory").unwrap(); + + let lock = prepare_and_lock_filesystem_cache(¤t); + + assert!(lock.is_some(), "the acquired liveness lock must survive a create failure"); + let contender = OpenOptions::new() + .read(true) + .write(true) + .open(filesystem_cache_lock_path(¤t)) + .unwrap(); + assert!( + matches!(contender.try_lock(), Err(TryLockError::WouldBlock)), + "the shared lock must still protect the cache path" + ); + assert!(stale.exists(), "the sweep must be skipped in the degraded mode"); + } + + #[test] + fn lock_open_failure_still_creates_the_cache_directory() { + let temp = TempDir::new().unwrap(); + let parent = temp.path().join("miden").join("packages"); + let current = parent.join("fedcba9876543210"); + // A directory at the lock path makes the lock file unopenable. + std::fs::create_dir_all(filesystem_cache_lock_path(¤t)).unwrap(); + + let lock = prepare_and_lock_filesystem_cache(¤t); + + assert!(lock.is_none(), "no lock can be held when its file cannot be opened"); + assert!( + current.is_dir(), + "the cache directory must be created so the build runs against the expected path" + ); + } + + #[test] + fn live_stale_fingerprint_survives_until_its_lock_is_released() { + let temp = TempDir::new().unwrap(); + let parent = temp.path().join("miden").join("packages"); + let current = parent.join("fedcba9876543210"); + let stale = parent.join("0123456789abcdef"); + fs::create_dir_all(&stale).unwrap(); + + let stale_lock_path = filesystem_cache_lock_path(&stale); + let stale_lock = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&stale_lock_path) + .unwrap(); + stale_lock.try_lock().unwrap(); + + let current_lock = + prepare_and_lock_filesystem_cache(¤t).expect("current cache must be locked"); + assert!(filesystem_cache_lock_path(¤t).exists()); + let current_contender = OpenOptions::new() + .read(true) + .write(true) + .open(filesystem_cache_lock_path(¤t)) + .unwrap(); + assert!( + matches!(current_contender.try_lock_shared(), Err(TryLockError::WouldBlock)), + "the builder lock is exclusive; nothing else may attach to a live cache" + ); + assert!(stale.exists(), "a live sibling cache must not be pruned"); + + drop(stale_lock); + drop(current_lock); + let second_lock = prepare_and_lock_filesystem_cache(¤t) + .expect("the next build must acquire the released lock"); + assert!(!stale.exists(), "the stale cache must be pruned after its build exits"); + assert!(stale_lock_path.exists(), "the stale sibling lock must remain permanent"); + + drop(second_lock); + } + + #[test] + fn same_fingerprint_contender_waits_for_the_first_builder() { + let temp = TempDir::new().unwrap(); + let parent = temp.path().join("miden").join("packages"); + let shared = parent.join("fedcba9876543210"); + let different = parent.join("0123456789abcdef"); + + let first = + prepare_and_lock_filesystem_cache(&shared).expect("first builder must lock the cache"); + + let (completed_tx, completed_rx) = mpsc::channel(); + let thread_cache = shared.clone(); + let contender = std::thread::spawn(move || { + completed_tx.send(prepare_and_lock_filesystem_cache(&thread_cache)).unwrap(); + }); + assert!( + completed_rx.recv_timeout(Duration::from_millis(100)).is_err(), + "an identical-fingerprint contender must wait for the first builder" + ); + + let different_lock = prepare_and_lock_filesystem_cache(&different) + .expect("a different-input builder must lock its own cache without waiting"); + assert!(shared.exists(), "the waiting contender's cache must not be pruned"); + + drop(first); + let contender_lock = completed_rx + .recv_timeout(Duration::from_secs(5)) + .unwrap() + .expect("the contender must acquire the lock once the first builder exits"); + contender.join().unwrap(); + + drop(different_lock); + drop(contender_lock); + } + + #[test] + fn arbitrary_cache_path_cannot_sweep_its_parent() { + let temp = TempDir::new().unwrap(); + let parent = temp.path().join("arbitrary-parent"); + let current = parent.join("cache"); + let fingerprint_sibling = parent.join("0123456789abcdef"); + let package_sibling = parent.join("unrelated.masp"); + fs::create_dir_all(&fingerprint_sibling).unwrap(); + fs::write(&package_sibling, b"unrelated").unwrap(); + + let lock = prepare_and_lock_filesystem_cache(¤t); + + assert!(lock.is_none()); + assert!(current.is_dir(), "an arbitrary cache path is still created"); + assert!(!filesystem_cache_lock_path(¤t).exists()); + assert!(fingerprint_sibling.exists()); + assert!(package_sibling.exists()); + } + + #[test] + fn fingerprint_name_outside_owned_layout_cannot_sweep_its_parent() { + let temp = TempDir::new().unwrap(); + let parent = temp.path().join("shared"); + let current = parent.join("fedcba9876543210"); + let fingerprint_sibling = parent.join("0123456789abcdef"); + let package_sibling = parent.join("unrelated.masp"); + fs::create_dir_all(&fingerprint_sibling).unwrap(); + fs::write(&package_sibling, b"unrelated").unwrap(); + + let lock = prepare_and_lock_filesystem_cache(¤t); + + assert!(lock.is_none()); + assert!(current.is_dir(), "an out-of-layout cache path is still created"); + assert!(!filesystem_cache_lock_path(¤t).exists()); + assert!(fingerprint_sibling.exists()); + assert!(package_sibling.exists()); + } + + /// Writes a minimal Miden project and Cargo manifest to `dir`. + fn write_project(dir: &Path, name: &str, dependencies: &str) { + std::fs::create_dir_all(dir).unwrap(); + std::fs::write( + dir.join("miden-project.toml"), + format!( + "[package]\nname = \"{name}\"\nversion = \"1.0.0\"\n\n[lib]\npath = \ + \"src/lib.rs\"\n{dependencies}" + ), + ) + .unwrap(); + std::fs::write( + dir.join("Cargo.toml"), + format!("[package]\nname = \"{name}\"\nversion = \"1.0.0\"\n"), + ) + .unwrap(); + } + + /// Computes a test fingerprint with a fresh source manager. + fn test_fingerprint(options: &Options, project_dir: &Path, version: &str, rev: &str) -> String { + fingerprint(options, project_dir, None, None, None, version, rev) + } + + #[test] + fn fingerprint_is_stable_for_unchanged_inputs() { + let temp = TempDir::new().unwrap(); + write_project(temp.path(), "root", ""); + let options = Options::default(); + + let first = test_fingerprint(&options, temp.path(), "1.2.3", "abc123"); + let second = test_fingerprint(&options, temp.path(), "1.2.3", "abc123"); + + assert_eq!(first, second); + assert!(is_fingerprint(&first)); + } + + #[test] + fn fingerprint_changes_with_manifest_closure() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("root"); + let dependency = temp.path().join("dependency"); + write_project( + &root, + "root", + "\n[dependencies]\ndependency = { path = \"../dependency\" }\n", + ); + write_project(&dependency, "dependency", ""); + let options = Options::default(); + let before = test_fingerprint(&options, &root, "1.2.3", "abc123"); + + std::fs::write( + dependency.join("Cargo.toml"), + "[package]\nname = \"dependency\"\nversion = \"2.0.0\"\n", + ) + .unwrap(); + let after = test_fingerprint(&options, &root, "1.2.3", "abc123"); + + assert_ne!(before, after); + } + + #[test] + fn fingerprint_walk_terminates_on_dependency_cycles() { + let temp = TempDir::new().unwrap(); + let first_project = temp.path().join("first"); + let second_project = temp.path().join("second"); + write_project( + &first_project, + "first", + "\n[dependencies]\nsecond = { path = \"../second\" }\n", + ); + write_project( + &second_project, + "second", + "\n[dependencies]\nfirst = { path = \"../first\" }\n", + ); + let options = Options::default(); + + let first = test_fingerprint(&options, &first_project, "1.2.3", "abc123"); + let second = test_fingerprint(&options, &first_project, "1.2.3", "abc123"); + + assert_eq!(first, second); + } + + #[test] + fn fingerprint_changes_with_preassembled_package_content() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("root"); + let package = temp.path().join("dependency.masp"); + write_project( + &root, + "root", + "\n[dependencies]\ndependency = { path = \"../dependency.masp\" }\n", + ); + std::fs::write(&package, b"first package").unwrap(); + let options = Options::default(); + let before = test_fingerprint(&options, &root, "1.2.3", "abc123"); + + std::fs::write(&package, b"different package").unwrap(); + let after = test_fingerprint(&options, &root, "1.2.3", "abc123"); + + assert_ne!(before, after); + } + + #[test] + fn project_load_failure_marker_is_stable_and_distinct() { + let temp = TempDir::new().unwrap(); + let options = Options::default(); + + let first = test_fingerprint(&options, temp.path(), "1.2.3", "abc123"); + let second = test_fingerprint(&options, temp.path(), "1.2.3", "abc123"); + assert_eq!(first, second); + + write_project(temp.path(), "root", ""); + let loadable = test_fingerprint(&options, temp.path(), "1.2.3", "abc123"); + + assert_ne!(first, loadable); + } + + #[test] + fn fingerprint_changes_with_build_options() { + let temp = TempDir::new().unwrap(); + write_project(temp.path(), "root", ""); + let options = Options::default(); + let baseline = test_fingerprint(&options, temp.path(), "1.2.3", "abc123"); + + let mut profile = options.clone(); + profile.profile = "release".into(); + assert_ne!(baseline, test_fingerprint(&profile, temp.path(), "1.2.3", "abc123")); + + let mut optimized = options.clone(); + optimized.optimize = OptLevel::Max; + assert_ne!(baseline, test_fingerprint(&optimized, temp.path(), "1.2.3", "abc123")); + } + + #[test] + fn fingerprint_walks_workspace_members_declared_by_manifest_path() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + std::fs::write( + root.join("miden-project.toml"), + "[workspace]\nmembers = [\"dep\", \"app\"]\n\n[workspace.package]\nversion = \ + \"1.0.0\"\n", + ) + .unwrap(); + write_project(&root.join("dep"), "dep", ""); + // The member is declared by its manifest FILE, a spelling miden-project accepts and + // classifies as a workspace member; the lookup must normalize it to the member dir. + write_project( + &root.join("app"), + "app", + "\n[dependencies]\ndep = { path = \"dep/miden-project.toml\" }\n", + ); + let options = Options::default(); + let before = test_fingerprint(&options, &root.join("app"), "1.2.3", "abc123"); + + std::fs::write( + root.join("dep").join("Cargo.toml"), + "[package]\nname = \"dep\"\nversion = \"2.0.0\"\n", + ) + .unwrap(); + let after = test_fingerprint(&options, &root.join("app"), "1.2.3", "abc123"); + + assert_ne!(before, after, "the member's manifest closure must be part of the fingerprint"); + } + + #[test] + fn fingerprint_changes_with_search_paths() { + let temp = TempDir::new().unwrap(); + write_project(temp.path(), "root", ""); + let options = Options::default(); + let baseline = test_fingerprint(&options, temp.path(), "1.2.3", "abc123"); + + // Search paths select which package a bare `-l` name resolves to, so they are part of + // the fingerprint like the sysroot and per-library paths feeding the same decision. + let mut with_search_path = options.clone(); + with_search_path.search_paths.push(temp.path().join("libs")); + assert_ne!(baseline, test_fingerprint(&with_search_path, temp.path(), "1.2.3", "abc123")); + } + + #[test] + fn fingerprint_changes_with_inherited_rustflags() { + let temp = TempDir::new().unwrap(); + write_project(temp.path(), "root", ""); + let options = Options::default(); + + let missing = fingerprint(&options, temp.path(), None, None, None, "1.2.3", "abc123"); + let present = fingerprint( + &options, + temp.path(), + Some(OsStr::new("-C target-feature=+bulk-memory")), + None, + None, + "1.2.3", + "abc123", + ); + + assert_ne!(missing, present); + } + + #[test] + fn fingerprint_changes_with_inherited_cargo_encoded_rustflags() { + let temp = TempDir::new().unwrap(); + write_project(temp.path(), "root", ""); + let options = Options::default(); + + let missing = fingerprint(&options, temp.path(), None, None, None, "1.2.3", "abc123"); + let present = fingerprint( + &options, + temp.path(), + None, + Some(OsStr::new("-Ctarget-feature=+bulk-memory\u{1f}--cfg=fixture")), + None, + "1.2.3", + "abc123", + ); + + assert_ne!(missing, present); + } + + #[test] + fn fingerprint_changes_with_inherited_rustup_toolchain() { + let temp = TempDir::new().unwrap(); + write_project(temp.path(), "root", ""); + let options = Options::default(); + + let missing = fingerprint(&options, temp.path(), None, None, None, "1.2.3", "abc123"); + let present = fingerprint( + &options, + temp.path(), + None, + None, + Some(OsStr::new("nightly-2026-08-05")), + "1.2.3", + "abc123", + ); + + assert_ne!(missing, present); + } + + #[test] + fn fingerprint_resolves_workspace_members_before_classifying_paths() { + let temp = TempDir::new().unwrap(); + let dependency = temp.path().join("dep.masp"); + let application = temp.path().join("app"); + write_project(&dependency, "dep", ""); + write_project(&application, "app", "\n[dependencies]\ndep.workspace = true\n"); + std::fs::write( + temp.path().join("miden-project.toml"), + "[workspace]\nmembers = [\"dep.masp\", \"app\"]\n\n[workspace.dependencies]\ndep = { \ + path = \"dep.masp\" }\n", + ) + .unwrap(); + std::fs::write( + temp.path().join("Cargo.toml"), + "[workspace]\nmembers = [\"dep.masp\", \"app\"]\n", + ) + .unwrap(); + let options = Options::default(); + let before = test_fingerprint(&options, &application, "1.2.3", "abc123"); + + std::fs::write( + dependency.join("Cargo.toml"), + "[package]\nname = \"dep\"\nversion = \"2.0.0\"\n", + ) + .unwrap(); + let after = test_fingerprint(&options, &application, "1.2.3", "abc123"); + + assert_ne!(before, after); + } + + #[test] + fn fingerprint_changes_with_workspace_manifests() { + let temp = TempDir::new().unwrap(); + let member = temp.path().join("member"); + write_project(&member, "member", ""); + std::fs::write( + temp.path().join("miden-project.toml"), + "[workspace]\nmembers = [\"member\"]\n", + ) + .unwrap(); + std::fs::write(temp.path().join("Cargo.toml"), "[workspace]\nmembers = [\"member\"]\n") + .unwrap(); + let options = Options::default(); + let before = test_fingerprint(&options, &member, "1.2.3", "abc123"); + + std::fs::write( + temp.path().join("Cargo.toml"), + "[workspace]\nmembers = [\"member\"]\nresolver = \"3\"\n", + ) + .unwrap(); + let after = test_fingerprint(&options, &member, "1.2.3", "abc123"); + + assert_ne!(before, after); + } + + #[test] + fn fingerprint_changes_with_compiler_identity() { + let temp = TempDir::new().unwrap(); + write_project(temp.path(), "root", ""); + let options = Options::default(); + let baseline = test_fingerprint(&options, temp.path(), "1.2.3", "abc123"); + + assert_ne!(baseline, test_fingerprint(&options, temp.path(), "1.2.4", "abc123")); + assert_ne!(baseline, test_fingerprint(&options, temp.path(), "1.2.3", "def456")); + } +} diff --git a/midenc-session/src/registry.rs b/midenc-session/src/registry.rs index 649d06ab1..e9f2a7a21 100644 --- a/midenc-session/src/registry.rs +++ b/midenc-session/src/registry.rs @@ -40,6 +40,12 @@ pub struct HybridPackageRegistry { artifacts: FxHashMap>>, #[cfg(any(test, feature = "std"))] filesystem_cache: Option, + /// Holds the current fingerprint's exclusive liveness lock for the registry's lifetime. + /// + /// The file is never read; dropping it releases this cache directory for pruning. `None` + /// means locking was intentionally skipped or cache preparation degraded after an error. + #[cfg(any(test, feature = "std"))] + filesystem_cache_lock: Option, } impl HybridPackageRegistry { @@ -54,6 +60,7 @@ impl HybridPackageRegistry { packages: Default::default(), artifacts: Default::default(), filesystem_cache: None, + filesystem_cache_lock: None, } } @@ -64,11 +71,60 @@ impl HybridPackageRegistry { } /// Get a new instance of the registry, using the current compiler options and an optional - /// filesystem cache directory + /// filesystem cache directory. + /// + /// The directory is created when possible and every package installed into the registry is + /// published into it, but a caller-supplied path is NEVER locked and NEVER used to sweep its + /// parent — a path shape alone does not prove midenc owns the location. The locking and + /// stale-cache pruning protocol runs only for cache paths the `Session` itself derives; see + /// [`Self::new_with_derived_filesystem_cache`]. #[cfg(any(test, feature = "std"))] pub fn new_with_filesystem_cache( options: &crate::Options, filesystem_cache: Option, + ) -> Result { + if let Some(filesystem_cache) = filesystem_cache.as_deref() + && let Err(err) = std::fs::create_dir_all(filesystem_cache) + { + log::warn!( + target: "package-registry", + "failed to create filesystem package cache '{}': {err}; keeping the cache configured so package publication reports the failure", + filesystem_cache.display() + ); + } + Self::construct(options, filesystem_cache, None) + } + + /// Get a new instance of the registry for a `Session`-derived filesystem cache path. + /// + /// This is the only entry that runs the cache lifecycle protocol: the fingerprint directory + /// is prepared and locked for the registry's lifetime, and dead sibling fingerprint + /// directories plus legacy flat `.masp` entries are pruned as defense in depth — FPI + /// expansions track the cache path themselves for correctness. It is crate-private because + /// the destructive sweep must be reachable only for internally derived paths, whose owned + /// `miden/packages/` layout the session derivation guarantees (and pruning + /// re-checks lexically as a belt; symlinked layouts are outside the contract). + /// + /// Cache preparation and cleanup failures are reported only through the `package-registry` + /// log target. The configured cache remains enabled after a preparation failure, so the + /// first package publication reports the concrete filesystem error to the caller. + #[cfg(any(test, feature = "std"))] + pub(crate) fn new_with_derived_filesystem_cache( + options: &crate::Options, + filesystem_cache: Option, + ) -> Result { + let filesystem_cache_lock = filesystem_cache + .as_deref() + .and_then(crate::package_cache::prepare_and_lock_filesystem_cache); + Self::construct(options, filesystem_cache, filesystem_cache_lock) + } + + /// Builds the registry with system libraries, link libraries, and the given cache state. + #[cfg(any(test, feature = "std"))] + fn construct( + options: &crate::Options, + filesystem_cache: Option, + filesystem_cache_lock: Option, ) -> Result { use alloc::string::ToString; @@ -79,6 +135,7 @@ impl HybridPackageRegistry { Self::empty() }; registry.filesystem_cache = filesystem_cache; + registry.filesystem_cache_lock = filesystem_cache_lock; // Load link libraries let core = crate::LinkLibrary::core(); @@ -132,7 +189,7 @@ impl HybridPackageRegistry { continue; }; let path = entry.path(); - if path.extension().is_none_or(|ext| !ext.eq_ignore_ascii_case("masp")) { + if path.extension().is_none_or(|ext| !ext.eq_ignore_ascii_case(Package::EXTENSION)) { continue; } @@ -152,13 +209,26 @@ impl HybridPackageRegistry { &mut self, package: Arc, ) -> Result { - use alloc::collections::btree_map::Entry as BTreeMapEntry; - - use hashbrown::hash_map::Entry; + let version = miden_project::Version::new(package.version.clone(), package.digest()); + log::trace!(target: "package-registry", "preparing to install package {}@{version}", &package.name); + if let Some(previous_digest) = self + .packages + .get(&package.name) + .and_then(|versions| versions.get(&package.version)) + .and_then(PackageRecord::digest) + .copied() + && previous_digest != package.digest() + { + log::trace!(target: "package-registry", "package already installed: {}@{version}", &package.name); + return Err(InstallPackageError::AlreadyInstalledWithDifferentDigest { + package: package.name.clone(), + version, + }); + } #[cfg(any(test, feature = "std"))] if let Some(filesystem_cache) = self.filesystem_cache.as_deref() { - package.write_masp_file(filesystem_cache).map_err(|err| { + write_package_atomically(&package, filesystem_cache).map_err(|err| { InstallPackageError::FilesystemCacheInsertion { package: package.name.clone(), err, @@ -166,8 +236,6 @@ impl HybridPackageRegistry { })?; } - let version = miden_project::Version::new(package.version.clone(), package.digest()); - log::trace!(target: "package-registry", "preparing to install package {}@{version}", &package.name); let record = PackageRecord::new( version.clone(), package.manifest.dependencies().map(|dep| { @@ -180,31 +248,10 @@ impl HybridPackageRegistry { ) }), ); - match self.packages.entry(package.name.clone()) { - Entry::Occupied(mut entry) => { - let versions = entry.get_mut(); - match versions.entry(package.version.clone()) { - BTreeMapEntry::Occupied(mut prev) => { - let prev_digest = prev.get().digest().copied(); - if prev_digest.is_none_or(|prev_digest| prev_digest == package.digest()) { - prev.insert(record); - } else { - log::trace!(target: "package-registry", "package already installed: {}@{version}", &package.name); - return Err(InstallPackageError::AlreadyInstalledWithDifferentDigest { - package: package.name.clone(), - version, - }); - } - } - BTreeMapEntry::Vacant(entry) => { - entry.insert(record); - } - } - } - Entry::Vacant(entry) => { - entry.insert([(package.version.clone(), record)].into_iter().collect()); - } - } + self.packages + .entry(package.name.clone()) + .or_default() + .insert(package.version.clone(), record); log::trace!(target: "package-registry", "installed {}@{version}", &package.name); @@ -217,6 +264,46 @@ impl HybridPackageRegistry { } } +/// Publishes `package` into `filesystem_cache` with an atomic replacement of the final path. +#[cfg(any(test, feature = "std"))] +fn write_package_atomically( + package: &Package, + filesystem_cache: &std::path::Path, +) -> std::io::Result<()> { + use std::{ + ffi::OsString, + io::{Error, ErrorKind}, + sync::atomic::{AtomicU64, Ordering}, + }; + + static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0); + + let package_name: &str = &package.name; + let destination = filesystem_cache.join(package_name).with_extension(Package::EXTENSION); + let destination_name = destination.file_name().ok_or_else(|| { + Error::new( + ErrorKind::InvalidInput, + format!("package cache destination '{}' has no file name", destination.display()), + ) + })?; + let mut temp_name = OsString::from("."); + temp_name.push(destination_name); + temp_name.push(format!( + ".tmp-{}-{}", + std::process::id(), + NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed) + )); + let temp_path = destination.with_file_name(temp_name); + + let result = package + .write_to_file(&temp_path) + .and_then(|()| std::fs::rename(&temp_path, &destination)); + if result.is_err() { + let _ = std::fs::remove_file(&temp_path); + } + result +} + impl HybridPackageRegistry { fn insert_record(&mut self, id: PackageId, record: PackageRecord) { self.packages @@ -288,3 +375,125 @@ impl PackageStore for HybridPackageRegistry { self.install_if_missing(package).map_err(Report::from) } } + +#[cfg(test)] +mod tests { + use std::fs::OpenOptions; + + use tempfile::TempDir; + + use super::*; + + #[test] + fn install_checks_conflicts_before_writing_and_rewrites_accepted_packages() { + let temp = TempDir::new().unwrap(); + let cache = temp.path().join("cache"); + std::fs::create_dir_all(&cache).unwrap(); + + let options = crate::Options::default(); + let package = crate::LinkLibrary::core().load(&options).unwrap(); + let package_name: &str = &package.name; + let cached_package = cache.join(package_name).with_extension(Package::EXTENSION); + let mut registry = HybridPackageRegistry::empty(); + registry.filesystem_cache = Some(cache); + + registry.install_if_missing(Arc::clone(&package)).unwrap(); + std::fs::write(&cached_package, b"damaged").unwrap(); + registry.install_if_missing(Arc::clone(&package)).unwrap(); + assert_ne!( + std::fs::read(&cached_package).unwrap(), + b"damaged", + "an accepted same-digest install must repair the cached package" + ); + assert!( + std::fs::read_dir(cached_package.parent().unwrap()).unwrap().all(|entry| !entry + .unwrap() + .file_name() + .to_string_lossy() + .contains(".tmp-")), + "successful publication must not leave its temporary file behind" + ); + + let mut conflict = (*crate::LinkLibrary::tx_kernel().load(&options).unwrap()).clone(); + conflict.name = package.name.clone(); + conflict.version = package.version.clone(); + assert_ne!(conflict.digest(), package.digest()); + std::fs::write(&cached_package, b"keep-on-conflict").unwrap(); + + assert!(matches!( + registry.install_if_missing(Arc::new(conflict)), + Err(InstallPackageError::AlreadyInstalledWithDifferentDigest { .. }) + )); + assert_eq!( + std::fs::read(&cached_package).unwrap(), + b"keep-on-conflict", + "a rejected install must not touch the cached package" + ); + + std::fs::remove_file(&cached_package).unwrap(); + std::fs::create_dir(&cached_package).unwrap(); + assert!(matches!( + registry.install_if_missing(Arc::clone(&package)), + Err(InstallPackageError::FilesystemCacheInsertion { .. }) + )); + assert!( + std::fs::read_dir(cached_package.parent().unwrap()).unwrap().all(|entry| !entry + .unwrap() + .file_name() + .to_string_lossy() + .contains(".tmp-")), + "failed publication must clean up its temporary file" + ); + } + + #[test] + fn constructor_prepares_and_locks_the_filesystem_cache() { + let temp = TempDir::new().unwrap(); + let current = temp.path().join("miden").join("packages").join("fedcba9876543210"); + + let registry = HybridPackageRegistry::new_with_derived_filesystem_cache( + &crate::Options::default(), + Some(current.clone()), + ) + .unwrap(); + + assert_eq!(registry.filesystem_cache_dir(), Some(current.as_path())); + assert!(current.is_dir()); + assert!(registry.filesystem_cache_lock.is_some()); + let lock_path = current.with_extension("lock"); + let contender = OpenOptions::new().read(true).write(true).open(lock_path).unwrap(); + assert!( + matches!(contender.try_lock(), Err(std::fs::TryLockError::WouldBlock)), + "the registry must hold the exclusive builder lock for its lifetime" + ); + } + + #[test] + fn public_constructor_never_sweeps_or_locks_a_caller_supplied_path() { + let temp = TempDir::new().unwrap(); + let parent = temp.path().join("miden").join("packages"); + let current = parent.join("fedcba9876543210"); + let sibling = parent.join("0123456789abcdef"); + std::fs::create_dir_all(&sibling).unwrap(); + + let registry = HybridPackageRegistry::new_with_filesystem_cache( + &crate::Options::default(), + Some(current.clone()), + ) + .unwrap(); + + assert!(current.is_dir(), "the configured cache directory is still created"); + assert!( + sibling.exists(), + "a caller-supplied path must never sweep its parent, owned-looking or not" + ); + assert!( + registry.filesystem_cache_lock.is_none(), + "a caller-supplied path is never locked" + ); + assert!( + !current.with_extension("lock").exists(), + "no lock file is created for a caller-supplied path" + ); + } +} diff --git a/sdk/base-macros/src/fpi.rs b/sdk/base-macros/src/fpi.rs index ff67779fd..1deaf6ff4 100644 --- a/sdk/base-macros/src/fpi.rs +++ b/sdk/base-macros/src/fpi.rs @@ -1162,18 +1162,9 @@ pub(crate) fn augment_foreign_account_bindings( let bindings = file.into_token_stream(); let package_includes = include_paths .into_iter() - .map(|path| { - let utf8_path = path.to_str().ok_or_else(|| { - Error::new( - Span::call_site(), - format!("path '{}' contains invalid UTF-8", path.display()), - ) - })?; - Ok(quote! { - const _: &[u8] = include_bytes!(#utf8_path); - }) - }) + .map(|path| package_include_tokens(&path)) .collect::>>()?; + let cache_tracking = package_cache_tracking_tokens(); Ok(quote! { #[doc(hidden)] @@ -1188,9 +1179,36 @@ pub(crate) fn augment_foreign_account_bindings( #(#trait_items)* #active_account_item #(#package_includes)* + #cache_tracking + }) +} + +/// Emits the rebuild-tracking constant for one dependency package file. +/// +/// Everything here lands in consumer scope, so the macro path is fully qualified: a +/// consumer-defined `include_bytes` must not shadow the rebuild tracking. +fn package_include_tokens(path: &std::path::Path) -> syn::Result { + let utf8_path = path.to_str().ok_or_else(|| { + Error::new(Span::call_site(), format!("path '{}' contains invalid UTF-8", path.display())) + })?; + Ok(quote! { + const _: &[u8] = ::core::include_bytes!(#utf8_path); }) } +/// Emits the dep-info record of the package cache location. +/// +/// The value carries the build-input fingerprint, so Cargo re-expands the consumer whenever the +/// fingerprint rotates — even when a stale cache directory survives on disk. The +/// `include_bytes!` constants cover content changes at an unchanged path. Both the type and the +/// macro are fully qualified because this lands in consumer scope, where a user-defined `Option` +/// or `option_env` must not shadow the tracking. +fn package_cache_tracking_tokens() -> TokenStream2 { + quote! { + const _: ::core::option::Option<&str> = ::core::option_env!("MIDENC_PACKAGE_CACHE"); + } +} + /// Builds one generated component trait and its empty attachment impl for the account wrapper. /// /// The trait is emitted with the wrapper struct's visibility (`vis`): a `pub` wrapper gets a `pub` @@ -1579,14 +1597,6 @@ fn resolve_dependency_package_path(dependency: &SelectedDependency) -> syn::Resu } let package_stems = dependency_package_stems(dependency); - let preferred_profile = env::var("PROFILE").unwrap_or_else(|_| "debug".to_string()); - let mut profiles = vec![preferred_profile.clone()]; - if preferred_profile != "release" { - profiles.push("release".to_string()); - } - if preferred_profile != "debug" { - profiles.push("debug".to_string()); - } if let Some(filesystem_cache_dir) = std::env::var_os("MIDENC_PACKAGE_CACHE") { let filesystem_cache_dir = PathBuf::from(filesystem_cache_dir); if let Some(package) = @@ -1597,14 +1607,21 @@ fn resolve_dependency_package_path(dependency: &SelectedDependency) -> syn::Resu Err(Error::new( Span::call_site(), - missing_dependency_package_message( + missing_cached_dependency_package_message( dependency, &package_stems, - &[filesystem_cache_dir], - &[], + &filesystem_cache_dir, ), )) } else { + let preferred_profile = env::var("PROFILE").unwrap_or_else(|_| "debug".to_string()); + let mut profiles = vec![preferred_profile.clone()]; + if preferred_profile != "release" { + profiles.push("release".to_string()); + } + if preferred_profile != "debug" { + profiles.push("debug".to_string()); + } let output_dirs = dependency_output_dirs(dependency, &profiles); for dir in &output_dirs { if let Some(package) = find_dependency_package_in_dir(dir, &package_stems)? { @@ -1618,6 +1635,32 @@ fn resolve_dependency_package_path(dependency: &SelectedDependency) -> syn::Resu } } +/// Formats the diagnostic for a missing dependency in the build-owned package cache. +fn missing_cached_dependency_package_message( + dependency: &SelectedDependency, + package_stems: &[String], + filesystem_cache_dir: &Path, +) -> String { + let expected_files = package_stems + .iter() + .map(|stem| format!("'{stem}.masp'")) + .collect::>() + .join(", "); + + format!( + "miden::generate! could not find a built `.masp` package for FPI dependency '{}' (import \ + '{}', root '{}'). FPI wrappers need the dependency package during Rust macro expansion \ + to read procedure roots. Expected one of these package names: {expected_files}. Searched \ + MIDENC_PACKAGE_CACHE directory '{}'. This cache is populated by the enclosing \ + midenc-driven build; compile this crate as part of that build so its dependency packages \ + are available during macro expansion.", + dependency.name, + dependency.import(), + dependency.root.display(), + filesystem_cache_dir.display(), + ) +} + /// Formats the diagnostic emitted when FPI wrapper generation cannot load a dependency package. fn missing_dependency_package_message( dependency: &SelectedDependency, @@ -1755,7 +1798,10 @@ fn find_dependency_package_in_dir( })? .into_iter() .map(|entry| entry.path()) - .filter(|path| path.extension().is_some_and(|ext| ext == "masp")) + .filter(|path| { + path.extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case(Package::EXTENSION)) + }) .collect::>(); packages.sort(); @@ -2120,6 +2166,44 @@ interface api { std::fs::remove_dir_all(temp_root).unwrap(); } + #[test] + fn emitted_cache_tracking_is_hygienic_in_consumer_scope() { + // The emitted constants land in the consumer crate's scope. Fully qualified paths keep + // the tracking working where a user defines their own `Option`, `option_env`, or + // `include_bytes`. + let tracking = package_cache_tracking_tokens().to_string(); + assert!(tracking.contains(":: core :: option :: Option"), "{tracking}"); + assert!(tracking.contains(":: core :: option_env !"), "{tracking}"); + assert!(tracking.contains("MIDENC_PACKAGE_CACHE"), "{tracking}"); + + let include = package_include_tokens(Path::new("/cache/dep.masp")).unwrap().to_string(); + assert!(include.contains(":: core :: include_bytes !"), "{include}"); + } + + #[test] + fn missing_cached_dependency_package_message_describes_the_cache_contract() { + let dependency = SelectedDependency { + name: "counter".to_string(), + root: PathBuf::from("/projects/counter"), + interface: DependencyInterface { + name: "counter".to_string(), + import: "miden:counter/counter@0.0.1".to_string(), + types: Vec::new(), + }, + }; + let stems = vec!["counter".to_string(), "counter_component".to_string()]; + let cache_dir = Path::new("/target/miden/packages/0123456789abcdef"); + + let message = missing_cached_dependency_package_message(&dependency, &stems, cache_dir); + + assert!(message.contains("'counter.masp'")); + assert!(message.contains("'counter_component.masp'")); + assert!(message.contains(&cache_dir.display().to_string())); + assert!(message.contains("populated by the enclosing midenc-driven build")); + assert!(!message.contains(" in release")); + assert!(!message.contains("target/miden/")); + } + #[test] fn procedure_root_key_rejects_nested_non_wit_export_path() { let path = MasmPath::validate( diff --git a/tests/integration-network/src/mockchain/support/helpers.rs b/tests/integration-network/src/mockchain/support/helpers.rs index 543b81d47..53b23b74d 100644 --- a/tests/integration-network/src/mockchain/support/helpers.rs +++ b/tests/integration-network/src/mockchain/support/helpers.rs @@ -69,6 +69,7 @@ pub(crate) fn block_on(future: F) -> F::Output { // COMPILATION // ================================================================================================ +/// Compiles a Rust project and returns its Miden package. pub(crate) fn compile_rust_package(project_path: impl AsRef, release: bool) -> Arc { let project_path = project_path.as_ref(); let config = WasmTranslationConfig::default(); @@ -79,13 +80,7 @@ pub(crate) fn compile_rust_package(project_path: impl AsRef, release: bool } let mut test = builder.build(); - let package = test.compile_package(); - let profile = if release { "release" } else { "debug" }; - package - .write_masp_file(project_path.join("target").join("miden").join(profile)) - .expect("failed to persist compiled Miden package"); - - package + test.compile_package() } /// Returns the root of the note script exported by the compiled package. diff --git a/tests/integration/src/end_to_end/examples/basic_wallet_package_sizes.rs b/tests/integration/src/end_to_end/examples/basic_wallet_package_sizes.rs index 35366050c..b7b7a62b5 100644 --- a/tests/integration/src/end_to_end/examples/basic_wallet_package_sizes.rs +++ b/tests/integration/src/end_to_end/examples/basic_wallet_package_sizes.rs @@ -1,7 +1,6 @@ use midenc_expect_test::expect; use midenc_frontend_wasm::WasmTranslationConfig; -use super::persist_cargo_miden_dependency; use crate::{CompilerTest, testing::stripped_mast_size_str}; fn no_debug_flags() -> [String; 2] { @@ -19,7 +18,6 @@ fn basic_wallet_and_p2id() { let account_package = account_test.compile_package(); assert!(account_package.is_library(), "expected library"); expect!["7982"].assert_eq(stripped_mast_size_str(&account_package).as_str()); - persist_cargo_miden_dependency("../../examples/basic-wallet", account_package.as_ref()); let mut tx_script_test = CompilerTest::rust_source_cargo_miden( "../../examples/basic-wallet-tx-script", diff --git a/tests/integration/src/end_to_end/examples/counter_note.rs b/tests/integration/src/end_to_end/examples/counter_note.rs index a1aeb8ba0..85f5eb69e 100644 --- a/tests/integration/src/end_to_end/examples/counter_note.rs +++ b/tests/integration/src/end_to_end/examples/counter_note.rs @@ -1,25 +1,13 @@ use miden_protocol::note::NoteScript; use midenc_frontend_wasm::WasmTranslationConfig; -use super::persist_cargo_miden_dependency; use crate::{CompilerTestBuilder, assert_helpers::assert_unique_protocol_export}; #[test] fn counter_note() { let config = WasmTranslationConfig::default(); - let counter_contract_builder = CompilerTestBuilder::rust_source_cargo_miden( - "../../examples/counter-contract", - config.clone(), - [], - ); - let mut counter_contract = counter_contract_builder.build(); - let counter_contract_package = counter_contract.compile_package(); - persist_cargo_miden_dependency( - "../../examples/counter-contract", - counter_contract_package.as_ref(), - ); - - // build and check counter-note + // The counter-note build compiles its counter-contract dependency itself and resolves it + // through the fingerprinted package cache; no separate dependency pre-build is needed. let builder = CompilerTestBuilder::rust_source_cargo_miden("../../examples/counter-note", config, []); diff --git a/tests/integration/src/end_to_end/examples/mod.rs b/tests/integration/src/end_to_end/examples/mod.rs index 73103ab5a..2ff4a3adc 100644 --- a/tests/integration/src/end_to_end/examples/mod.rs +++ b/tests/integration/src/end_to_end/examples/mod.rs @@ -1,5 +1,3 @@ -use std::path::Path; - mod auth_component_no_auth; mod auth_component_rpo_falcon512; mod basic_wallet_package_sizes; @@ -10,12 +8,3 @@ mod counter_note; mod fibonacci; mod is_prime; mod storage_metadata; - -fn persist_cargo_miden_dependency( - project_path: impl AsRef, - package: &miden_mast_package::Package, -) { - package - .write_masp_file(project_path.as_ref().join("target").join("miden").join("release")) - .expect("failed to persist compiled Miden dependency package"); -} diff --git a/tests/integration/src/sdk/mod.rs b/tests/integration/src/sdk/mod.rs index 6867e6bea..1c877e9b2 100644 --- a/tests/integration/src/sdk/mod.rs +++ b/tests/integration/src/sdk/mod.rs @@ -1,8 +1,8 @@ -use std::{path::Path, sync::Arc}; +use std::{fs, path::Path, sync::Arc}; use miden_assembly::ast::types::{FunctionType, Type}; use miden_core::serde::Serializable; -use miden_mast_package::{PackageExport, ProcedureExport, QualifiedProcedureName}; +use miden_mast_package::{Package, PackageExport, ProcedureExport, QualifiedProcedureName}; use miden_protocol::note::NoteScript; use midenc_frontend_wasm::WasmTranslationConfig; @@ -32,16 +32,6 @@ pub(crate) fn note_script_program( .unwrap() } -/// Writes a compiled package where `miden::generate!` expects Cargo Miden dependency artifacts. -fn persist_cargo_miden_dependency( - project_path: impl AsRef, - package: &miden_mast_package::Package, -) { - package - .write_masp_file(project_path.as_ref().join("target").join("miden").join("release")) - .expect("failed to persist compiled Miden dependency package"); -} - fn find_manifest_procedure<'a>( package: &'a miden_mast_package::Package, description: &str, @@ -135,6 +125,230 @@ fn assert_component_export_signatures_match_wit(package: &miden_mast_package::Pa ); } +/// Creates a generated workspace containing the existing basic-wallet/swapp-note FPI pair. +#[track_caller] +fn fpi_package_cache_regression_project() -> crate::Project { + let sdk_path = sdk_crate_path(); + let workspace_manifest = r#" +[workspace] +members = ["basic-wallet", "swapp-note"] +resolver = "3" + +[profile.release] +opt-level = "z" +panic = "abort" +debug = false +"#; + let basic_wallet_cargo = format!( + r#" +cargo-features = ["trim-paths"] + +[package] +name = "basic_wallet" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +miden = {{ path = "{}" }} +"#, + sdk_path.display(), + ); + let swapp_note_cargo = format!( + r#" +cargo-features = ["trim-paths"] + +[package] +name = "swapp-note" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +miden = {{ path = "{}" }} + +[package.metadata.miden] +project-kind = "note-script" + +[package.metadata.component] +package = "miden:swapp-note" + +[package.metadata.miden.dependencies] +"miden:basic-wallet" = {{ path = "../basic-wallet" }} + +[package.metadata.component.target.dependencies] +"miden:basic-wallet" = {{ path = "../basic-wallet/target/generated-wit/" }} +"#, + sdk_path.display(), + ); + let swapp_note_miden_manifest = r#" +[package] +name = "swapp-note" +version = "0.1.0" + +[lib] +kind = "note" +namespace = "miden:swapp-note/miden-swapp-note@0.1.0" +path = "src/lib.rs" + +[dependencies] +basic-wallet = { path = "../basic-wallet" } + +[package.metadata.miden.dependencies] +basic-wallet = { wit = "../basic-wallet/target/generated-wit/" } +"#; + let original_swapp_note_source = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../fixtures/components/swapp-note/src/lib.rs" + )); + let swapp_note_mutation = " let offered_asset = ¬e_assets[0];"; + assert_eq!( + original_swapp_note_source.matches(swapp_note_mutation).count(), + 1, + "the swapp-note fixture mutation must match exactly once" + ); + let swapp_note_source = original_swapp_note_source.replacen( + swapp_note_mutation, + " let offered_asset = ¬e_assets[0];\n let foreign_wallet = \ + Wallet::new(self.creator);\n foreign_wallet.receive_asset(*offered_asset);", + 1, + ); + + project("fpi_package_cache_stale_root") + .file("Cargo.toml", workspace_manifest) + .file( + ".cargo/config.toml", + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../examples/basic-wallet/.cargo/config.toml" + )), + ) + .file("basic-wallet/Cargo.toml", &basic_wallet_cargo) + .file( + "basic-wallet/miden-project.toml", + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../examples/basic-wallet/miden-project.toml" + )), + ) + .file( + "basic-wallet/src/lib.rs", + include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../examples/basic-wallet/src/lib.rs" + )), + ) + .file("swapp-note/Cargo.toml", &swapp_note_cargo) + .file("swapp-note/miden-project.toml", swapp_note_miden_manifest) + .file("swapp-note/src/lib.rs", &swapp_note_source) + .build() +} + +/// Reads the named dependency package from a compiled consumer's filesystem cache. +fn read_cached_dependency_package(test: &CompilerTest, package_name: &str) -> Package { + let cache_dir = test + .session + .filesystem_package_cache_dir() + .expect("a Cargo Miden project must have a filesystem package cache"); + let path = fs::read_dir(&cache_dir) + .unwrap_or_else(|err| { + panic!("failed to read package cache '{}': {err}", cache_dir.display()) + }) + .map(|entry| entry.expect("failed to read an entry from the package cache").path()) + .find(|path| { + path.file_stem().is_some_and(|stem| stem == package_name) + && path + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case(Package::EXTENSION)) + }) + .unwrap_or_else(|| { + panic!("failed to find package '{package_name}' in cache '{}'", cache_dir.display()) + }); + let bytes = fs::read(&path) + .unwrap_or_else(|err| panic!("failed to read cached package '{}': {err}", path.display())); + Package::read_from_bytes_unchecked(&bytes) + .unwrap_or_else(|err| panic!("failed to decode cached package '{}': {err}", path.display())) +} + +/// Returns the canonical integer representation of a procedure digest's field elements. +fn procedure_digest_felts(digest: &miden_core::Word) -> [u64; 4] { + let elements = digest.as_elements(); + [ + elements[0].as_canonical_u64(), + elements[1].as_canonical_u64(), + elements[2].as_canonical_u64(), + elements[3].as_canonical_u64(), + ] +} + +/// Returns true when MASM reconstructs every felt in `digest` from its decimal `u32` limbs. +/// +/// This intentionally follows the current lowering shape for `u64` immediates. If codegen changes +/// that sequence, this helper can report a stale-root failure even when the embedded digest is +/// current, so update the recognizer alongside such a lowering change. +fn masm_contains_procedure_digest(masm: &str, digest: &miden_core::Word) -> bool { + let pattern = procedure_digest_felts(digest) + .into_iter() + .rev() + .map(|felt| { + let low = felt as u32; + let high = (felt >> u32::BITS) as u32; + format!("push.{high} push.{low} swap.1 mul.4294967296 add") + }) + .collect::>() + .join(" "); + let normalized_masm = masm.split_whitespace().collect::>().join(" "); + normalized_masm.contains(&pattern) +} + +/// Returns true when Wasm constructs every felt of `digest` for an FPI call. +/// +/// The four root elements remain consecutive among the module's `i64.const` instructions even +/// when checked felt construction inserts control flow between them. +fn wat_contains_procedure_digest(wat: &str, digest: &miden_core::Word) -> bool { + let tokens = wat.split_whitespace().collect::>(); + let constants = tokens + .windows(2) + .filter(|tokens| tokens[0].trim_matches(['(', ')']) == "i64.const") + .filter_map(|tokens| tokens[1].trim_matches(['(', ')']).parse::().ok()) + .collect::>(); + let expected = procedure_digest_felts(digest).map(|felt| felt as i64); + constants.windows(expected.len()).any(|window| window == expected) +} + +/// Builds `consumer` directly with one prepopulated package cache and returns its Wasm as WAT. +fn build_consumer_wat_with_package_cache( + consumer: &Path, + cargo_target_dir: &Path, + package_cache_dir: &Path, +) -> String { + let output = std::process::Command::new("cargo") + .args(["build", "--release", "--locked", "--manifest-path"]) + .arg(consumer.join("Cargo.toml")) + .env("CARGO_TARGET_DIR", cargo_target_dir) + .env("MIDENC_PACKAGE_CACHE", package_cache_dir) + .env("RUSTFLAGS", "--cfg miden -C target-feature=+bulk-memory,+wide-arithmetic") + .env_remove("CARGO_ENCODED_RUSTFLAGS") + .current_dir(consumer) + .output() + .expect("failed to spawn Cargo for the option_env isolation fixture"); + assert!( + output.status.success(), + "option_env isolation fixture failed to build:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + + let wasm_path = cargo_target_dir.join("wasm32-wasip2/release/swapp_note.wasm"); + let wasm = fs::read(&wasm_path).unwrap_or_else(|err| { + panic!("failed to read fixture Wasm '{}': {err}", wasm_path.display()) + }); + midenc_frontend_wasm::wasm_to_wat(&wasm).expect("failed to print fixture Wasm") +} + fn component_namespace(name: &str) -> String { let package = name.replace('_', "-"); format!("miden:{package}/miden-{package}@0.0.1") @@ -255,10 +469,6 @@ fn rust_sdk_cross_ctx_account_and_note() { [], ); let account_package = test.compile_package(); - persist_cargo_miden_dependency( - "../fixtures/components/cross-ctx-account", - account_package.as_ref(), - ); assert!(account_package.is_library()); let exports = account_package .manifest @@ -312,10 +522,6 @@ fn rust_sdk_cross_ctx_account_and_note_word() { [], ); let account_package = test.compile_package(); - persist_cargo_miden_dependency( - "../fixtures/components/cross-ctx-account-word", - account_package.as_ref(), - ); assert!(account_package.is_library()); assert_component_export_signatures_match_wit(account_package.as_ref()); let expected_module_prefix = "::\"miden:cross-ctx-account-word/"; @@ -411,6 +617,209 @@ fn rust_sdk_account_package_build_is_deterministic() { } } +/// A dependency package rewrite must invalidate the FPI roots embedded by `include_bytes!`. +#[test] +fn rust_sdk_fpi_reexpands_after_dependency_package_changes() { + let project = fpi_package_cache_regression_project(); + let consumer = project.root().join("swapp-note"); + let dependency_source = project.root().join("basic-wallet/src/lib.rs"); + let dependency_cargo_manifest = project.root().join("basic-wallet/Cargo.toml"); + let dependency_miden_manifest = project.root().join("basic-wallet/miden-project.toml"); + let config = WasmTranslationConfig::default(); + + let mut first_build = CompilerTest::rust_source_cargo_miden(&consumer, config.clone(), []); + let first_masm = first_build.masm_src(); + let first_cache = first_build.session.filesystem_package_cache_dir().unwrap(); + let first_dependency = read_cached_dependency_package(&first_build, "basic-wallet"); + let first_export = + find_manifest_procedure(&first_dependency, "basic-wallet receive-asset export", |path| { + path.ends_with("::\"receive-asset\"") + }); + let first_root = first_export.digest; + assert!( + masm_contains_procedure_digest(&first_masm, &first_root), + "consumer MASM does not contain the first receive-asset root {:?}", + procedure_digest_felts(&first_root), + ); + + let original_source = fs::read_to_string(&dependency_source).unwrap(); + assert_eq!( + original_source.matches(" self.add_asset(asset);").count(), + 1, + "fixture mutation must match exactly once" + ); + let changed_source = original_source.replacen( + " self.add_asset(asset);", + " self.add_asset(asset);\n self.remove_asset(asset);\n \ + self.add_asset(asset);", + 1, + ); + fs::write(&dependency_source, changed_source).unwrap(); + + let mut second_build = CompilerTest::rust_source_cargo_miden(&consumer, config.clone(), []); + let second_masm = second_build.masm_src(); + let second_cache = second_build.session.filesystem_package_cache_dir().unwrap(); + let second_dependency = read_cached_dependency_package(&second_build, "basic-wallet"); + let second_export = + find_manifest_procedure(&second_dependency, "basic-wallet receive-asset export", |path| { + path.ends_with("::\"receive-asset\"") + }); + let second_root = second_export.digest; + + assert_eq!(first_cache, second_cache, "both builds must exercise the same cache path"); + assert_ne!(first_root, second_root, "the dependency implementation must change its root"); + assert_ne!(first_masm, second_masm, "the consumer must be recompiled with the new root"); + assert!( + !masm_contains_procedure_digest(&second_masm, &first_root), + "consumer MASM still contains the stale receive-asset root {:?}", + procedure_digest_felts(&first_root), + ); + assert!( + masm_contains_procedure_digest(&second_masm, &second_root), + "consumer MASM does not contain the new receive-asset root {:?}", + procedure_digest_felts(&second_root), + ); + + for manifest_path in [&dependency_cargo_manifest, &dependency_miden_manifest] { + let original_manifest = fs::read_to_string(manifest_path).unwrap(); + assert_eq!( + original_manifest.matches("version = \"0.1.0\"").count(), + 1, + "expected exactly one package-version field in {}", + manifest_path.display() + ); + let mut changed_manifest = + original_manifest.replacen("version = \"0.1.0\"", "version = \"0.1.1\"", 1); + if manifest_path == &dependency_miden_manifest { + assert_eq!( + changed_manifest.matches("@0.1.0").count(), + 1, + "expected exactly one namespace version in {}", + manifest_path.display() + ); + changed_manifest = changed_manifest.replacen("@0.1.0", "@0.1.1", 1); + } + fs::write(manifest_path, changed_manifest).unwrap(); + } + + let mut third_build = CompilerTest::rust_source_cargo_miden(&consumer, config, []); + let third_masm = third_build.masm_src(); + let third_cache = third_build.session.filesystem_package_cache_dir().unwrap(); + let third_dependency = read_cached_dependency_package(&third_build, "basic-wallet"); + let third_export = + find_manifest_procedure(&third_dependency, "basic-wallet receive-asset export", |path| { + path.ends_with("::\"receive-asset\"") + }); + let third_root = third_export.digest; + + assert_ne!(second_cache, third_cache, "manifest changes must rotate the cache path"); + assert!( + !second_cache.exists(), + "the obsolete fingerprint directory must be pruned after rotation: {}", + second_cache.display() + ); + for stale_root in [first_root, second_root] { + if stale_root != third_root { + assert!( + !masm_contains_procedure_digest(&third_masm, &stale_root), + "consumer MASM still contains stale receive-asset root {:?}", + procedure_digest_felts(&stale_root), + ); + } + } + assert!( + masm_contains_procedure_digest(&third_masm, &third_root), + "consumer MASM does not contain the post-rotation receive-asset root {:?}", + procedure_digest_felts(&third_root), + ); +} + +/// Changing only `MIDENC_PACKAGE_CACHE` must re-expand FPI roots in an unchanged consumer. +#[test] +fn rust_sdk_fpi_reexpands_after_only_package_cache_env_changes() { + let project = fpi_package_cache_regression_project(); + let dependency = project.root().join("basic-wallet"); + let consumer = project.root().join("swapp-note"); + let dependency_source = dependency.join("src/lib.rs"); + let config = WasmTranslationConfig::default(); + + let mut first_dependency_build = + CompilerTest::rust_source_cargo_miden(&dependency, config.clone(), []); + let first_package = first_dependency_build.compile_package(); + let first_root = find_manifest_procedure( + &first_package, + "original basic-wallet receive-asset export", + |path| path.ends_with("::\"receive-asset\""), + ) + .digest; + + let original_source = fs::read_to_string(&dependency_source).unwrap(); + assert_eq!( + original_source.matches(" self.add_asset(asset);").count(), + 1, + "fixture mutation must match exactly once" + ); + let changed_source = original_source.replacen( + " self.add_asset(asset);", + " self.add_asset(asset);\n self.remove_asset(asset);\n \ + self.add_asset(asset);", + 1, + ); + fs::write(&dependency_source, changed_source).unwrap(); + + let mut second_dependency_build = + CompilerTest::rust_source_cargo_miden(&dependency, config, []); + let second_package = second_dependency_build.compile_package(); + let second_root = find_manifest_procedure( + &second_package, + "changed basic-wallet receive-asset export", + |path| path.ends_with("::\"receive-asset\""), + ) + .digest; + assert_ne!(first_root, second_root, "the prepopulated packages must embed different roots"); + + let first_cache = project.root().join("option-env-cache-a"); + let second_cache = project.root().join("option-env-cache-b"); + for cache in [&first_cache, &second_cache] { + fs::create_dir_all(cache).unwrap(); + } + first_package + .write_masp_file(&first_cache) + .expect("failed to prepopulate the first package cache"); + second_package + .write_masp_file(&second_cache) + .expect("failed to prepopulate the second package cache"); + + let cargo_target_dir = project.root().join("option-env-cargo-target"); + if cargo_target_dir.exists() { + fs::remove_dir_all(&cargo_target_dir).unwrap(); + } + + // Both Cargo invocations have identical arguments, sources, manifests, generated WIT, target + // directory, and flags. The cache environment value is the sole changed build input. + let first_wat = + build_consumer_wat_with_package_cache(&consumer, &cargo_target_dir, &first_cache); + let second_wat = + build_consumer_wat_with_package_cache(&consumer, &cargo_target_dir, &second_cache); + + assert!( + wat_contains_procedure_digest(&first_wat, &first_root), + "the first consumer build did not embed its cache's receive-asset root" + ); + assert!( + !wat_contains_procedure_digest(&first_wat, &second_root), + "the first consumer build unexpectedly embedded the second cache's root" + ); + assert!( + wat_contains_procedure_digest(&second_wat, &second_root), + "changing MIDENC_PACKAGE_CACHE did not re-expand the consumer with the second root" + ); + assert!( + !wat_contains_procedure_digest(&second_wat, &first_root), + "the second consumer build retained the stale root from the first cache" + ); +} + #[test] fn rust_sdk_cross_ctx_word_arg_account_and_note() { let config = WasmTranslationConfig::default(); @@ -420,11 +829,6 @@ fn rust_sdk_cross_ctx_word_arg_account_and_note() { [], ); let account_package = test.compile_package(); - persist_cargo_miden_dependency( - "../fixtures/components/cross-ctx-account-word-arg", - account_package.as_ref(), - ); - assert!(account_package.is_library()); let expected_module_prefix = "::\"miden:cross-ctx-account-word-arg/"; let expected_function_suffix = "\"process-word\""; diff --git a/tools/cargo-miden/src/commands/build.rs b/tools/cargo-miden/src/commands/build.rs index cede95933..4ab3277da 100644 --- a/tools/cargo-miden/src/commands/build.rs +++ b/tools/cargo-miden/src/commands/build.rs @@ -35,6 +35,9 @@ impl BuildCommand { None => cwd.join("Cargo.toml"), }; let input = InputFile::from_path(&manifest_path).unwrap(); + // This root session is expected to name one selected package. Package-cache closure + // fingerprinting relies on workspace builds reaching this point once per selected member; + // an unselected workspace-root manifest is rejected during project preparation. let session = Rc::new( compiler_opts .into_session(input, None, None) diff --git a/tools/cargo-miden/tests/masm_dependency.rs b/tools/cargo-miden/tests/masm_dependency.rs index f3a6a248b..ca069b655 100644 --- a/tools/cargo-miden/tests/masm_dependency.rs +++ b/tools/cargo-miden/tests/masm_dependency.rs @@ -11,7 +11,11 @@ //! to derive a per-target role across package boundaries: the Rust root is //! `TargetRole::Root`, the MASM library is a `TargetRole::Dependency`. -use std::{env, fs, path::Path}; +use std::{ + env, fs, + path::Path, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; use cargo_miden::run; @@ -155,6 +159,7 @@ fn build_rust_project_with_masm_path_dependency() { write_library_manifest(&project_path, project_name, dependency_name, "../masm_dep"); env::set_current_dir(&project_path).unwrap(); + let build_started_at = SystemTime::now(); let result = run(["cargo", "miden", "build"].into_iter().map(|s| s.to_string())); env::set_current_dir(&restore_dir).unwrap(); @@ -172,17 +177,28 @@ fn build_rust_project_with_masm_path_dependency() { // The root's own package building is not by itself evidence that the dependency was built // through a source provider: it would also hold if the dependency had been resolved from a // registry, or skipped. A materialized `.masp` for the dependency is produced only by - // assembling it from its Miden Assembly sources. - let dependency_package = project_path - .join("target") - .join("miden") - .join("packages") - .join(format!("{dependency_name}.masp")); + // assembling it from its Miden Assembly sources. The package cache is uniqued by the build + // inputs, so the package lives inside the build's fingerprint directory. + // `.masp` is `miden_mast_package::Package::EXTENSION`, spelled inline because cargo-miden + // no longer links miden-mast-package. + let dependency_package_name = format!("{dependency_name}.masp"); + let dependency_package = + crate::utils::package_cache_fingerprint_dir(&project_path, &dependency_package_name) + .join(dependency_package_name); assert!( dependency_package.exists(), "expected the masm dependency to be assembled and materialized at {}", dependency_package.display() ); + let modified = dependency_package.metadata().unwrap().modified().unwrap(); + let attribution_floor = + build_started_at.checked_sub(Duration::from_secs(1)).unwrap_or(UNIX_EPOCH); + assert!( + modified >= attribution_floor, + "expected this build to rewrite {}, but its modification time {modified:?} predates the \ + one-second-tolerant build attribution floor {attribution_floor:?}", + dependency_package.display() + ); fs::remove_dir_all(&root).unwrap(); } diff --git a/tools/cargo-miden/tests/p2id_cargo_miden_build.rs b/tools/cargo-miden/tests/p2id_cargo_miden_build.rs index c840dee4c..521452c7b 100644 --- a/tools/cargo-miden/tests/p2id_cargo_miden_build.rs +++ b/tools/cargo-miden/tests/p2id_cargo_miden_build.rs @@ -1,4 +1,7 @@ -use std::{env, fs}; +use std::{ + env, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; use cargo_miden::run; @@ -8,8 +11,9 @@ use crate::utils::{current_dir_lock, workspace_root}; /// /// The `p2id-note` example depends on the `basic-wallet` example as a Miden dependency. When /// `cargo miden build` compiles `p2id-note`, it compiles `basic-wallet` as a dependency. The -/// resulting dependency package must be materialized to `basic-wallet/target/miden/release` rather -/// than only living in the in-memory package registry. +/// resulting dependency package must be materialized under p2id-note's +/// `target/miden/packages//` package cache rather than only living in the +/// in-memory package registry. #[test] fn p2id_build_materializes_basic_wallet_dependency() { let _cwd_lock = current_dir_lock(); @@ -29,23 +33,10 @@ fn p2id_build_materializes_basic_wallet_dependency() { let examples = workspace_root().join("examples"); let p2id_note_dir = examples.join("p2id-note"); - // The materialized dependency package we expect `cargo miden build` to produce on disk. - let dep_release_dir = p2id_note_dir.join("target").join("miden").join("packages"); - let dep_package = dep_release_dir.join("basic-wallet.masp"); - - // Make sure the basic-wallet dependency package is not already materialized on disk, so that we - // can attribute its presence after the build to the p2id-note build alone. - if dep_release_dir.exists() { - fs::remove_dir_all(&dep_release_dir).unwrap(); - } - assert!( - !dep_package.exists(), - "basic-wallet dependency package should not be materialized before the build" - ); - // Build the p2id-note project, which pulls in basic-wallet as a Miden dependency. let restore_dir = env::current_dir().unwrap(); env::set_current_dir(&p2id_note_dir).unwrap(); + let build_started_at = SystemTime::now(); let result = run(["cargo", "miden", "build", "--release"].into_iter().map(|s| s.to_string())); env::set_current_dir(&restore_dir).unwrap(); @@ -61,10 +52,23 @@ fn p2id_build_materializes_basic_wallet_dependency() { .unwrap_build_output(); assert_eq!(output.len(), 1, "expected a single p2id-note package artifact, got {output:?}"); - // The build must have materialized the basic-wallet dependency package on disk. + // The build must have materialized the basic-wallet dependency package on disk, inside the + // build's single fingerprint directory. + let dep_package = + crate::utils::package_cache_fingerprint_dir(&p2id_note_dir, "basic-wallet.masp") + .join("basic-wallet.masp"); assert!( dep_package.exists(), "expected basic-wallet dependency package to be materialized at {}", dep_package.display() ); + let modified = dep_package.metadata().unwrap().modified().unwrap(); + let attribution_floor = + build_started_at.checked_sub(Duration::from_secs(1)).unwrap_or(UNIX_EPOCH); + assert!( + modified >= attribution_floor, + "expected this build to rewrite {}, but its modification time {modified:?} predates the \ + one-second-tolerant build attribution floor {attribution_floor:?}", + dep_package.display() + ); } diff --git a/tools/cargo-miden/tests/utils.rs b/tools/cargo-miden/tests/utils.rs index bf0182841..62c324496 100644 --- a/tools/cargo-miden/tests/utils.rs +++ b/tools/cargo-miden/tests/utils.rs @@ -44,6 +44,74 @@ pub(crate) fn current_dir_lock() -> CurrentDirGuard { } } +/// Returns the newest build-fingerprint directory containing `expected_package`. +/// +/// Another live build may retain a different fingerprint directory, so the package itself rather +/// than directory cardinality identifies the cache produced by the build under test. +pub(crate) fn package_cache_fingerprint_dir(project_dir: &Path, expected_package: &str) -> PathBuf { + let package_cache_dir = project_dir.join("target").join("miden").join("packages"); + let entries = fs::read_dir(&package_cache_dir) + .unwrap_or_else(|err| { + panic!( + "expected the package cache directory '{}' to exist after the build: {err}", + package_cache_dir.display() + ) + }) + .collect::>(); + + let mut listing = Vec::new(); + let mut candidates = Vec::new(); + for entry in entries { + let entry = entry.unwrap(); + let path = entry.path(); + if !path.is_dir() { + listing.push(path.display().to_string()); + continue; + } + // Only compiler-owned fingerprint directories are candidates; unrelated directories may + // coexist under the cache parent and must not attribute an old package to this build. + let is_fingerprint = path.file_name().and_then(|name| name.to_str()).is_some_and(|name| { + name.len() == 16 + && name.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }); + if !is_fingerprint { + listing.push(path.display().to_string()); + continue; + } + + let contents = fs::read_dir(&path) + .map(|entries| { + entries + .filter_map(Result::ok) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .collect::>() + }) + .unwrap_or_else(|err| vec![format!("")]); + listing.push(format!("{}: {contents:?}", path.display())); + + let expected = path.join(expected_package); + if let Ok(metadata) = expected.metadata() { + let modified = metadata.modified().unwrap_or(std::time::UNIX_EPOCH); + candidates.push((modified, path)); + } + } + + // `read_dir` order is unspecified, so equal mtimes at the filesystem's precision leave this + // diagnostic attribution tie nondeterministic. No production behavior depends on the choice. + candidates + .into_iter() + .max_by_key(|(modified, _)| *modified) + .map(|(_, path)| path) + .unwrap_or_else(|| { + panic!( + "expected a fingerprint directory in '{}' containing '{expected_package}'; \ + entries:\n{}", + package_cache_dir.display(), + listing.join("\n") + ) + }) +} + pub(crate) fn project_template_arg(template: &str) -> String { let template = template.trim_start_matches("--"); let templates_path = match env::var("TEST_LOCAL_TEMPLATES_PATH") {