mod artifacts;
mod drift;
mod env;
mod installer_detect;
mod preserve;
pub use installer_detect::installer_stages;
use anodizer_core::determinism::AggregateKind;
use anodizer_core::git::worktree::Worktree;
use anodizer_core::harness_signing::EphemeralSigningKeys;
use anodizer_core::log::{StageLogger, Verbosity};
use anodizer_core::{AllowList, ArtifactRow, CURRENT_SCHEMA_VERSION, DeterminismReport, DriftRow};
use anyhow::{Context, Result};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use strum::{EnumIter, IntoEnumIterator};
use artifacts::{
ArtifactInfo, copy_artifacts_to_dump, discover_artifacts, hash_artifacts,
infer_stage_from_path, prune_dump_to_drifted,
};
use drift::{inject_drift_byte, pick_first_artifact_for_stage, summarize_drift};
use env::{BuildSubprocessEnv, build_subprocess_env};
use preserve::{
ContextInputs, preserve_dist_tree, preserve_raw_binaries, remove_preserved_on_drift,
write_preserved_dist_context,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, EnumIter)]
pub enum StageId {
Build,
Source,
Upx,
Archive,
Nfpm,
Makeself,
Snapcraft,
Sbom,
Sign,
Checksum,
CargoPackage,
Docker,
Msi,
Nsis,
Dmg,
Pkg,
Srpm,
Appbundle,
Appimage,
Flatpak,
}
impl StageId {
pub fn as_str(self) -> &'static str {
match self {
StageId::Build => "build",
StageId::Source => "source",
StageId::Upx => "upx",
StageId::Archive => "archive",
StageId::Nfpm => "nfpm",
StageId::Makeself => "makeself",
StageId::Snapcraft => "snapcraft",
StageId::Sbom => "sbom",
StageId::Sign => "sign",
StageId::Checksum => "checksum",
StageId::CargoPackage => "cargo-package",
StageId::Docker => "docker",
StageId::Msi => "msi",
StageId::Nsis => "nsis",
StageId::Dmg => "dmg",
StageId::Pkg => "pkg",
StageId::Srpm => "srpm",
StageId::Appbundle => "appbundle",
StageId::Appimage => "appimage",
StageId::Flatpak => "flatpak",
}
}
pub fn from_token(token: &str) -> Option<Self> {
Self::iter().find(|s| s.as_str() == token)
}
}
const PRESERVE_SET: &[&str] = &["validate", "before", "templatefiles"];
fn stage_requires_binary(stage: StageId) -> bool {
match stage {
StageId::Source | StageId::Srpm | StageId::CargoPackage => false,
StageId::Build => false,
StageId::Upx
| StageId::Archive
| StageId::Nfpm
| StageId::Makeself
| StageId::Snapcraft
| StageId::Sbom
| StageId::Sign
| StageId::Checksum
| StageId::Docker
| StageId::Msi
| StageId::Nsis
| StageId::Dmg
| StageId::Pkg
| StageId::Appbundle
| StageId::Appimage
| StageId::Flatpak => true,
}
}
fn compute_extra_skip(requested: &[StageId]) -> Vec<String> {
use anodizer_core::context::VALID_RELEASE_SKIPS;
use anodizer_core::determinism_runner::SIDE_EFFECT_STAGES;
let requested_names: BTreeSet<&str> = requested.iter().map(|s| s.as_str()).collect();
let force_build = requested.iter().copied().any(stage_requires_binary);
VALID_RELEASE_SKIPS
.iter()
.copied()
.filter(|name| !requested_names.contains(name))
.filter(|name| !(force_build && *name == "build"))
.filter(|name| !PRESERVE_SET.contains(name))
.filter(|name| !SIDE_EFFECT_STAGES.contains(name))
.map(str::to_string)
.collect()
}
fn matches_artifact_pattern(pattern: &str, artifact: &str) -> bool {
if let Some(suffix) = pattern.strip_prefix('*') {
return artifact.ends_with(suffix);
}
pattern == artifact
}
fn stage_docker_context(
worktree_path: &Path,
context_dir: &Path,
docker_cfg: &ResolvedDockerConfig,
log: &StageLogger,
) -> Result<usize> {
use anodizer_core::target::map_target;
let _ = std::fs::remove_dir_all(context_dir);
std::fs::create_dir_all(context_dir)
.with_context(|| format!("creating docker staging dir {}", context_dir.display()))?;
let mut staged = 0usize;
for (triple, bin_path) in discover_per_triple_binaries(worktree_path)? {
let (os, arch) = map_target(&triple);
let file_name = bin_path
.file_name()
.map(std::ffi::OsStr::to_os_string)
.unwrap_or_else(|| std::ffi::OsString::from("anodizer"));
let dest_dir = context_dir.join(&os).join(&arch);
std::fs::create_dir_all(&dest_dir)
.with_context(|| format!("creating staging platform dir {}", dest_dir.display()))?;
let dest = dest_dir.join(&file_name);
std::fs::copy(&bin_path, &dest)
.with_context(|| format!("staging {} → {}", bin_path.display(), dest.display()))?;
log.verbose(&format!(
"staged {} → {}",
bin_path.display(),
dest.display()
));
staged += 1;
}
let dockerfile_abs = worktree_path.join(&docker_cfg.dockerfile);
anodizer_stage_docker::copy_dockerfile(
&dockerfile_abs.to_string_lossy(),
context_dir,
false,
log,
"determinism docker",
)?;
if !docker_cfg.extra_files.is_empty() {
anodizer_stage_docker::stage_extra_files(
&docker_cfg.extra_files,
context_dir,
Some(worktree_path),
false,
log,
"determinism docker",
)?;
}
Ok(staged)
}
fn discover_per_triple_binaries(worktree_path: &Path) -> Result<Vec<(String, PathBuf)>> {
let target_root = worktree_path.join(".det-tmp").join("target");
let entries = match std::fs::read_dir(&target_root) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e).with_context(|| format!("reading {}", target_root.display())),
};
let mut out = Vec::new();
for entry in entries {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let name = entry.file_name();
let triple = name.to_string_lossy();
if triple == "release" || triple == "debug" || triple.starts_with('.') {
continue;
}
let release_dir = entry.path().join("release");
if !release_dir.is_dir() {
continue;
}
for bin in std::fs::read_dir(&release_dir)
.with_context(|| format!("reading {}", release_dir.display()))?
{
let bin = bin?;
if !bin.file_type()?.is_file() {
continue;
}
let path = bin.path();
if path
.file_name()
.and_then(|s| s.to_str())
.is_some_and(|n| n.starts_with('.'))
{
continue;
}
match path.extension().and_then(|s| s.to_str()) {
None | Some("exe") => out.push((triple.to_string(), path)),
_ => continue,
}
}
}
out.sort();
Ok(out)
}
#[derive(Debug, Clone)]
pub struct ResolvedDockerConfig {
pub dockerfile: String,
pub extra_files: Vec<String>,
pub build_args: Vec<(String, String)>,
}
pub struct Harness {
pub repo_root: PathBuf,
pub commit: String,
pub stages: Vec<StageId>,
pub explicit_stages: Vec<StageId>,
pub require_tools: bool,
pub runs: u32,
pub sde: i64,
pub allowlist: AllowList,
pub report_path: PathBuf,
pub inject_drift: Option<String>,
pub targets: Option<Vec<String>>,
pub preserve_dist: Option<PathBuf>,
pub version_hint: String,
pub child_snapshot: bool,
pub verbosity: Verbosity,
pub docker_backend_hint: Option<String>,
pub docker_configs: Vec<ResolvedDockerConfig>,
pub docker_declared: bool,
pub crate_name: Option<String>,
pub config_tools: BTreeMap<StageId, Vec<String>>,
pub disk_abs_floor_bytes: u64,
pub disk_safety_factor: f64,
}
fn require_c_toolchain<P>(targets: &[String], host_is_windows_msvc: bool, probe: P) -> Result<()>
where
P: Fn(&str) -> bool,
{
let needs_clang_cl = if targets.is_empty() {
host_is_windows_msvc
} else {
targets
.iter()
.any(|t| anodizer_core::target::is_windows_msvc(t))
};
if needs_clang_cl && !probe("clang-cl") {
anyhow::bail!(
"windows-msvc determinism requires `clang-cl` (LLVM) on PATH for byte-reproducible \
C objects (zstd-sys/ring/aws-lc-sys/…); install LLVM or add its bin dir to PATH"
);
}
Ok(())
}
impl Harness {
fn gate_installer_stages<P>(
&self,
effective_stages: &[StageId],
probe: P,
) -> Result<Vec<StageId>>
where
P: Fn(&str) -> bool,
{
let gate = installer_detect::filter_available_with_probe(
effective_stages,
&self.config_tools,
probe,
);
let hard_fail_set: &[StageId] = if self.require_tools {
effective_stages
} else {
&self.explicit_stages
};
let hard_failed = gate.explicitly_skipped(hard_fail_set);
if !hard_failed.is_empty() {
anyhow::bail!(installer_detect::missing_tool_error(
&hard_failed,
self.require_tools
));
}
let warn_log = StageLogger::new("check-determinism", self.verbosity);
for (stage, tool) in &gate.skipped {
warn_log.warn(&format!(
"skipped stage `{}` for this run — `{}` is not on PATH \
(no artifacts emitted)",
stage.as_str(),
tool
));
}
Ok(gate.available)
}
pub fn run(&self) -> Result<DeterminismReport> {
let mut per_run_hashes: Vec<BTreeMap<String, ArtifactInfo>> =
Vec::with_capacity(self.runs as usize);
let skip_sign_for_preserve = self.preserve_dist.is_some()
&& (std::env::var_os("COSIGN_KEY").is_some()
|| std::env::var_os("GPG_PRIVATE_KEY").is_some());
let effective_stages: Vec<StageId> = if skip_sign_for_preserve {
self.stages
.iter()
.copied()
.filter(|s| *s != StageId::Sign)
.collect()
} else {
self.stages.clone()
};
let effective_stages =
self.gate_installer_stages(&effective_stages, installer_detect::host_tool_probe)?;
require_c_toolchain(
self.targets.as_deref().unwrap_or(&[]),
anodizer_core::determinism::host_is_windows_msvc(),
anodizer_core::tool_detect::on_path,
)?;
let signing_keys: Option<EphemeralSigningKeys> =
if effective_stages.contains(&StageId::Sign) {
Some(anodizer_core::harness_signing::provision_ephemeral_keys(
self.sde,
)?)
} else {
None
};
let worktree_root = std::env::var_os("RUNNER_TEMP")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| self.repo_root.join(".det-worktrees"));
let _ = std::fs::create_dir_all(&worktree_root);
let worktree_path =
worktree_root.join(format!("anodize-determinism-{}", std::process::id()));
let shared_cargo_home =
worktree_root.join(format!("anodize-determinism-cargo-{}", std::process::id()));
std::fs::create_dir_all(&shared_cargo_home)?;
let effective_preserve_dest: Option<std::path::PathBuf> =
self.preserve_dist.as_ref().map(|base| {
if let Some(ref name) = self.crate_name {
base.join(name)
} else {
base.clone()
}
});
let log = StageLogger::new("check-determinism", self.verbosity);
let mut max_prior_peak: Option<u64> = None;
let mut probe_gap_warned = false;
for run_idx in 0..self.runs {
log.detail(&format!("run {} of {}", run_idx + 1, self.runs));
let free_before = anodizer_core::disk::available_bytes(&worktree_root);
if free_before.is_none() && !probe_gap_warned {
log.warn(&format!(
"free-space probe unavailable on {} — determinism disk-headroom guard \
disabled for this invocation (a permanently-failing probe would otherwise \
silently skip the guard for an entire CI history)",
worktree_root.display()
));
probe_gap_warned = true;
}
self.guard_run_headroom(&log, run_idx, &worktree_root, free_before, max_prior_peak)?;
let _ = std::fs::remove_dir_all(&worktree_path);
let worktree = Worktree::add(&self.repo_root, &worktree_path, &self.commit)
.with_context(|| format!("creating worktree for determinism run {}", run_idx))?;
if run_idx == 0 {
log.verbose("prefetching dependencies into shared cargo home (online, retried)");
anodizer_core::determinism_runner::prefetch_deps(
worktree.path(),
&shared_cargo_home,
)
.context("prefetching dependencies for the determinism harness")?;
}
let env =
self.build_isolated_env(&worktree, &shared_cargo_home, signing_keys.as_ref())?;
let sampler = anodizer_core::disk::FreeSpaceSampler::start(
&worktree_root,
anodizer_core::disk::DEFAULT_SAMPLE_INTERVAL,
);
self.run_build_pipeline(worktree.path(), &env, &effective_stages)
.with_context(|| format!("building pipeline for determinism run {}", run_idx))?;
if effective_stages.contains(&StageId::CargoPackage) {
self.run_cargo_package(worktree.path(), &env)
.with_context(|| {
format!(
"running cargo-package stage for determinism run {}",
run_idx
)
})?;
}
if effective_stages.contains(&StageId::Docker) {
let docker_explicitly_requested =
self.require_tools || self.explicit_stages.contains(&StageId::Docker);
self.run_docker_stage(worktree.path(), &env, docker_explicitly_requested)
.with_context(|| {
format!("running docker stage for determinism run {}", run_idx)
})?;
}
let min_free_during = sampler.stop();
if let (Some(before), Some(min_free)) = (free_before, min_free_during) {
let peak = anodizer_core::disk::RunPeak {
free_before: before,
min_free_during: min_free,
};
let consumed = peak.consumed_bytes();
let dist_size = anodizer_core::disk::dir_size_bytes(&worktree.path().join("dist"));
log.verbose(&format!(
"disk peak run {}: consumed {} (min free {}, worktree dist {})",
run_idx + 1,
anodizer_core::disk::format_gib(consumed),
anodizer_core::disk::format_gib(min_free),
anodizer_core::disk::format_gib(dist_size),
));
max_prior_peak = Some(max_prior_peak.map_or(consumed, |m| m.max(consumed)));
}
let artifacts = discover_artifacts(worktree.path())?;
if let Some(stage) = self.inject_drift.as_deref() {
match pick_first_artifact_for_stage(&artifacts, stage) {
Some(victim) => {
inject_drift_byte(victim).with_context(|| {
format!(
"injecting drift byte into {} on run {}",
victim.display(),
run_idx
)
})?;
}
None => {
let summary: Vec<String> = artifacts
.iter()
.map(|p| {
let s = p.to_string_lossy();
format!(
" {} → {}",
p.display(),
artifacts::infer_stage_from_path(&s)
)
})
.collect();
StageLogger::new("check-determinism", self.verbosity).warn(&format!(
"--inject-drift={} matched no artifact on run {}; \
discovered artifacts ({}):\n{}",
stage,
run_idx,
artifacts.len(),
summary.join("\n")
));
}
}
}
per_run_hashes.push(hash_artifacts(worktree.path(), &artifacts)?);
if let Some(parent) = self.report_path.parent() {
let dump_root = parent.join("drift-bins").join(format!("run-{}", run_idx));
copy_artifacts_to_dump(worktree.path(), &artifacts, &dump_root, &log)
.with_context(|| {
format!(
"dumping artifacts to {} for determinism run {}",
dump_root.display(),
run_idx
)
})?;
}
if run_idx == 0
&& let Some(dest) = effective_preserve_dest.as_ref()
{
preserve_dist_tree(worktree.path(), dest).with_context(|| {
format!(
"preserving run-0 dist tree from {} to {}",
worktree.path().join("dist").display(),
dest.display()
)
})?;
preserve_raw_binaries(worktree.path(), dest, &log).with_context(|| {
format!(
"preserving raw binaries from {} into {}",
worktree.path().display(),
dest.display()
)
})?;
}
drop(worktree);
if let Some(after) = anodizer_core::disk::available_bytes(&worktree_root) {
log.verbose(&format!(
"disk free {}: {} after run {} (worktree reclaimed)",
worktree_root.display(),
anodizer_core::disk::format_gib(after),
run_idx + 1
));
}
}
let _ = std::fs::remove_dir_all(&shared_cargo_home);
let report = self.build_report(per_run_hashes);
if let Some(parent) = self.report_path.parent() {
prune_dump_to_drifted(&parent.join("drift-bins"), &report);
}
if let Some(dest) = effective_preserve_dest.as_ref() {
if report.drift_count > 0 {
remove_preserved_on_drift(dest, &log);
} else {
write_preserved_dist_context(
dest,
ContextInputs {
report: &report,
harness_targets: self.targets.as_deref(),
version_hint: &self.version_hint,
},
&log,
)
.with_context(|| {
format!(
"writing context.json under preserved dist {}",
dest.display()
)
})?;
}
}
Ok(report)
}
fn guard_run_headroom(
&self,
log: &StageLogger,
run_idx: u32,
vol: &Path,
free: Option<u64>,
prior_peak: Option<u64>,
) -> Result<()> {
use anodizer_core::disk::{HeadroomDecision, evaluate_headroom, format_gib};
let Some(free) = free else {
return Ok(());
};
let vols = anodizer_core::disk::mounted_volumes();
let mounts = if vols.is_empty() {
String::new()
} else {
format!(" — /Volumes: [{}]", vols.join(", "))
};
log.verbose(&format!(
"disk free {}: {} before run {}{}",
vol.display(),
format_gib(free),
run_idx + 1,
mounts
));
match evaluate_headroom(
run_idx,
free,
self.disk_abs_floor_bytes,
prior_peak,
self.disk_safety_factor,
&vol.display().to_string(),
) {
HeadroomDecision::Proceed => Ok(()),
HeadroomDecision::Abort(shortfall) => {
let msg = shortfall.message();
log.error(&msg);
anyhow::bail!(msg)
}
}
}
fn build_isolated_env(
&self,
worktree: &Worktree,
cargo_home: &Path,
signing_keys: Option<&EphemeralSigningKeys>,
) -> Result<HashMap<String, String>> {
let tmpdir = worktree.path().join(".det-tmp");
std::fs::create_dir_all(&tmpdir)?;
let cargo_target = tmpdir.join("target");
let home_dir = tmpdir.join("home");
std::fs::create_dir_all(&home_dir)?;
Ok(build_subprocess_env(&BuildSubprocessEnv {
cargo_home,
cargo_target: &cargo_target,
tmpdir: &tmpdir,
home_dir: &home_dir,
sde: self.sde,
worktree: worktree.path(),
targets: self.targets.as_deref().unwrap_or(&[]),
signing_keys,
}))
}
fn run_build_pipeline(
&self,
worktree_path: &Path,
env: &HashMap<String, String>,
effective_stages: &[StageId],
) -> Result<()> {
let exe = anodizer_core::determinism_runner::current_anodize_binary()?;
let extra_skip = compute_extra_skip(effective_stages);
anodizer_core::determinism_runner::run_build_pipeline_subprocess(
&anodizer_core::determinism_runner::ChildInvocation {
anodize_binary: &exe,
worktree_path,
env,
targets: self.targets.as_deref(),
extra_skip: &extra_skip,
snapshot: self.child_snapshot,
crate_name: self.crate_name.as_deref(),
verbosity: self.verbosity,
},
)
}
fn run_cargo_package(&self, worktree_path: &Path, env: &HashMap<String, String>) -> Result<()> {
let log = StageLogger::new("check-determinism", self.verbosity);
anodizer_core::cargo_package::package_workspace(worktree_path, env, &log)?;
let source = worktree_path
.join(".det-tmp")
.join("target")
.join("package");
let dest = worktree_path.join("dist").join("cargo-package");
std::fs::create_dir_all(&dest)
.with_context(|| format!("creating dest dir {}", dest.display()))?;
if !source.exists() {
return Ok(());
}
for entry in
std::fs::read_dir(&source).with_context(|| format!("reading {}", source.display()))?
{
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("crate") {
let name = path
.file_name()
.with_context(|| format!("crate path lacks filename: {}", path.display()))?;
let target = dest.join(name);
std::fs::copy(&path, &target).with_context(|| {
format!("copying {} → {}", path.display(), target.display())
})?;
}
}
Ok(())
}
fn run_docker_stage(
&self,
worktree_path: &Path,
env: &HashMap<String, String>,
explicitly_requested: bool,
) -> Result<()> {
let log = StageLogger::new("check-determinism", self.verbosity);
if self.docker_configs.is_empty() {
if self.docker_declared {
log.warn(
"skipped docker stage for this run — crate declares dockers_v2 but all \
entries are skipped in this context (`skip:` / empty-rendered dockerfile); \
docker byte-verification produced no image (matches a normal release)",
);
}
return Ok(());
}
if self.docker_backend_hint.as_deref() == Some("podman") {
let msg = "docker stage requested but project config has `use: podman` \
(Linux-only); the determinism harness only probes BuildKit-based \
builds. Verify podman image byte-stability outside the harness.";
if explicitly_requested {
anyhow::bail!(
"{msg} Refusing to report byte-stability for an image the harness \
cannot probe — remove `docker` from --stages or build the image \
with BuildKit."
);
}
log.warn(&format!("{msg} The docker stage is skipped for this run."));
return Ok(());
}
match anodizer_core::docker_detect::buildx_available() {
Ok(true) => {}
Ok(false) | Err(_) => {
if explicitly_requested {
anyhow::bail!(
"docker stage requested via --stages but `docker buildx` is not \
available on PATH; the determinism gate cannot byte-verify the \
image. Provision a `docker-container` buildx driver \
(docker/setup-buildx-action) before running the harness."
);
}
log.warn(
"skipped docker stage for this run — `docker buildx` is not available on PATH \
(no artifacts emitted)",
);
return Ok(());
}
}
for (idx, docker_cfg) in self.docker_configs.iter().enumerate() {
self.run_one_docker_config(
worktree_path,
env,
&log,
idx,
docker_cfg,
explicitly_requested,
)?;
}
Ok(())
}
fn run_one_docker_config(
&self,
worktree_path: &Path,
env: &HashMap<String, String>,
log: &StageLogger,
idx: usize,
docker_cfg: &ResolvedDockerConfig,
explicitly_requested: bool,
) -> Result<()> {
let dockerfile_abs = worktree_path.join(&docker_cfg.dockerfile);
if !dockerfile_abs.exists() {
if explicitly_requested {
anyhow::bail!(
"dockers_v2[{idx}] dockerfile '{}' does not exist in the rebuilt \
worktree; the determinism docker stage cannot byte-verify it. \
Ensure the dockerfile is committed.",
docker_cfg.dockerfile
);
}
log.warn(&format!(
"skipped dockers_v2[{idx}] for this run — dockerfile '{}' not found in the \
rebuilt worktree (no artifacts emitted)",
docker_cfg.dockerfile
));
return Ok(());
}
let context_dir = worktree_path
.join(".det-tmp")
.join(format!("docker-context-{idx}"));
let staged = stage_docker_context(worktree_path, &context_dir, docker_cfg, log)?;
if staged == 0 {
if explicitly_requested {
anyhow::bail!(
"docker stage requested via --stages but the build produced no \
per-triple binaries to stage under <os>/<arch>/; the COPY in \
dockers_v2[{idx}]'s dockerfile cannot resolve. Check that the \
requested --targets built successfully."
);
}
log.warn(
"skipped docker stage for this run — no per-triple binaries to stage \
under <os>/<arch>/ (no artifacts emitted)",
);
return Ok(());
}
let output = anodizer_core::docker_build::oci_build_fixture(
&context_dir,
&format!("anodize/det:harness-{idx}"),
&docker_cfg.build_args,
env,
log,
)?;
let dest_dir = worktree_path
.join("dist")
.join("docker")
.join(idx.to_string());
std::fs::create_dir_all(&dest_dir)
.with_context(|| format!("creating dest dir {}", dest_dir.display()))?;
let target = dest_dir.join("image.oci.tar");
std::fs::copy(&output.oci_tar_path, &target).with_context(|| {
format!(
"copying {} → {}",
output.oci_tar_path.display(),
target.display()
)
})?;
if let Some(digest) = output.image_digest.as_deref() {
std::fs::write(dest_dir.join("image.digest"), digest).with_context(|| {
format!(
"writing image digest to {}",
dest_dir.join("image.digest").display()
)
})?;
}
Ok(())
}
fn build_report(
&self,
per_run_hashes: Vec<BTreeMap<String, ArtifactInfo>>,
) -> DeterminismReport {
let mut all_names: BTreeSet<String> = BTreeSet::new();
for run in &per_run_hashes {
for name in run.keys() {
all_names.insert(name.clone());
}
}
let mut artifacts: Vec<ArtifactRow> = Vec::new();
let mut drift: Vec<DriftRow> = Vec::new();
let mut drift_count: u32 = 0;
let manifest_members = self.produced_member_basenames(&per_run_hashes);
let combined_markers = self.produced_combined_markers(&per_run_hashes);
for name in &all_names {
let mut hashes: Vec<String> = Vec::with_capacity(per_run_hashes.len());
let mut last_info: Option<&ArtifactInfo> = None;
for run in &per_run_hashes {
match run.get(name) {
Some(info) => {
hashes.push(info.hash.clone());
last_info = Some(info);
}
None => hashes.push("<missing>".into()),
}
}
let info = last_info.expect("artifact name came from union of run maps");
let all_equal =
hashes.iter().all(|h| h == &hashes[0]) && !hashes.iter().any(|h| h == "<missing>");
let classification =
self.classify(name, &all_names, &manifest_members, &combined_markers);
if matches!(classification, Classification::Unclassified) {
artifacts.push(ArtifactRow {
name: name.clone(),
path: info.relative_path.clone(),
size_bytes: info.size_bytes,
stage: info.stage.clone(),
deterministic: all_equal,
nondeterministic_reason: None,
hash: if all_equal {
Some(hashes[0].clone())
} else {
None
},
hashes: if all_equal { vec![] } else { hashes.clone() },
});
if !all_equal {
drift.push(DriftRow {
artifact: name.clone(),
hashes,
differing_bytes_summary: Some(
"unclassified produced file drifted across runs; if it is a \
combined checksums file, mark it combined=true so its members \
can be evaluated — otherwise it is a real regression"
.into(),
),
});
drift_count += 1;
}
continue;
}
let mut aggregate_excuse: Option<String> = None;
if !all_equal && matches!(classification, Classification::Aggregate) {
let kind = self
.aggregate_kind_for_name(name, &combined_markers)
.expect("Aggregate classification ⇒ a registered kind matches");
match self.evaluate_aggregate(
kind.as_ref(),
name,
&per_run_hashes,
&combined_markers,
) {
AggregateVerdict::Excused(reason) => aggregate_excuse = Some(reason),
AggregateVerdict::Regression(members) => {
artifacts.push(ArtifactRow {
name: name.clone(),
path: info.relative_path.clone(),
size_bytes: info.size_bytes,
stage: info.stage.clone(),
deterministic: false,
nondeterministic_reason: None,
hash: None,
hashes: hashes.clone(),
});
let joined = members.join(", ");
drift.push(DriftRow {
artifact: format!("{name} → {joined}"),
hashes,
differing_bytes_summary: Some(format!(
"aggregate member(s) [{joined}] drifted and are not allow-listed; \
a gated artifact regressed (surfaced via the {name} aggregate)"
)),
});
drift_count += 1;
continue;
}
AggregateVerdict::FailClosed(reason) => {
artifacts.push(ArtifactRow {
name: name.clone(),
path: info.relative_path.clone(),
size_bytes: info.size_bytes,
stage: info.stage.clone(),
deterministic: false,
nondeterministic_reason: None,
hash: None,
hashes: hashes.clone(),
});
drift.push(DriftRow {
artifact: name.clone(),
hashes,
differing_bytes_summary: Some(reason),
});
drift_count += 1;
continue;
}
}
}
let signed_artifact_drift = !all_equal && info.stage == "sign";
let allow_reason = aggregate_excuse
.or_else(|| self.resolve_allow_reason(name))
.or_else(|| {
if signed_artifact_drift {
Some(
"signed artifact: signature bytes vary by signer \
(cosign ECDSA random nonce); validate via \
`cosign verify-blob` / `gpg --verify`"
.into(),
)
} else {
None
}
});
if all_equal {
artifacts.push(ArtifactRow {
name: name.clone(),
path: info.relative_path.clone(),
size_bytes: info.size_bytes,
stage: info.stage.clone(),
deterministic: true,
nondeterministic_reason: allow_reason.clone(),
hash: Some(hashes[0].clone()),
hashes: vec![],
});
} else {
artifacts.push(ArtifactRow {
name: name.clone(),
path: info.relative_path.clone(),
size_bytes: info.size_bytes,
stage: info.stage.clone(),
deterministic: false,
nondeterministic_reason: allow_reason.clone(),
hash: None,
hashes: hashes.clone(),
});
if allow_reason.is_none() {
let summary = summarize_drift(name, &per_run_hashes);
drift.push(DriftRow {
artifact: name.clone(),
hashes,
differing_bytes_summary: summary,
});
drift_count += 1;
}
}
}
DeterminismReport {
schema_version: CURRENT_SCHEMA_VERSION,
anodize_version: env!("CARGO_PKG_VERSION").into(),
commit: self.commit.clone(),
commit_timestamp: self.sde,
runs: self.runs,
stages_under_test: self.stages.iter().map(|s| s.as_str().into()).collect(),
allowlist: self.allowlist.clone(),
artifacts,
drift,
drift_count,
}
}
fn resolve_allow_reason(&self, artifact_name: &str) -> Option<String> {
for entry in &self.allowlist.compile_time {
if matches_artifact_pattern(&entry.artifact, artifact_name) {
return Some(entry.reason.clone());
}
}
for entry in &self.allowlist.runtime {
if matches_artifact_pattern(&entry.artifact, artifact_name) {
return Some(entry.reason.clone());
}
}
None
}
fn produced_member_basenames(
&self,
per_run_hashes: &[BTreeMap<String, ArtifactInfo>],
) -> BTreeSet<String> {
let mut out = BTreeSet::new();
let Some(run) = per_run_hashes.last() else {
return out;
};
for (name, info) in run {
if !anodizer_core::determinism::ArtifactsManifest.matches(name) {
continue;
}
let Some(full) = info.full.as_deref() else {
continue;
};
if let Ok(units) = anodizer_core::determinism::ArtifactsManifest.members_by_unit(full) {
out.extend(units.into_values());
}
}
out
}
fn produced_combined_markers(
&self,
per_run_hashes: &[BTreeMap<String, ArtifactInfo>],
) -> BTreeSet<String> {
let mut out = BTreeSet::new();
let Some(run) = per_run_hashes.last() else {
return out;
};
for (name, info) in run {
if !anodizer_core::determinism::ArtifactsManifest.matches(name) {
continue;
}
let Some(full) = info.full.as_deref() else {
continue;
};
if let Ok(markers) =
anodizer_core::determinism::combined_checksum_members_from_manifest(full)
{
out.extend(markers);
}
}
out
}
fn aggregate_kind_for_name(
&self,
name: &str,
combined_markers: &BTreeSet<String>,
) -> Option<Box<dyn anodizer_core::determinism::AggregateKind>> {
if let Some(kind) = anodizer_core::determinism::aggregate_kind_for(name) {
return Some(kind);
}
if combined_markers.contains(basename(name)) {
return Some(Box::new(anodizer_core::determinism::CombinedChecksums));
}
None
}
fn classify(
&self,
name: &str,
all_names: &BTreeSet<String>,
manifest_members: &BTreeSet<String>,
combined_markers: &BTreeSet<String>,
) -> Classification {
if self
.aggregate_kind_for_name(name, combined_markers)
.is_some()
{
return Classification::Aggregate;
}
if let Some(stem) = strip_sidecar_suffix(name) {
let stem_is_primary = self.is_primary(stem, manifest_members)
|| all_names
.iter()
.any(|n| n.as_str() == stem || basename(n) == basename(stem));
if stem_is_primary {
return Classification::Sidecar;
}
}
if self.is_primary(name, manifest_members) {
return Classification::Primary;
}
Classification::Unclassified
}
fn is_primary(&self, name: &str, manifest_members: &BTreeSet<String>) -> bool {
let base = basename(name);
infer_stage_from_path(name) != "unknown"
|| self.resolve_allow_reason(name).is_some()
|| base == anodizer_core::dist::METADATA_JSON
|| manifest_members.contains(base)
}
fn evaluate_aggregate(
&self,
kind: &dyn anodizer_core::determinism::AggregateKind,
name: &str,
per_run_hashes: &[BTreeMap<String, ArtifactInfo>],
combined_markers: &BTreeSet<String>,
) -> AggregateVerdict {
let mut visited: BTreeSet<String> = BTreeSet::new();
visited.insert(name.to_string());
self.evaluate_aggregate_inner(kind, name, per_run_hashes, combined_markers, &mut visited)
}
fn evaluate_aggregate_inner(
&self,
kind: &dyn anodizer_core::determinism::AggregateKind,
name: &str,
per_run_hashes: &[BTreeMap<String, ArtifactInfo>],
combined_markers: &BTreeSet<String>,
visited: &mut BTreeSet<String>,
) -> AggregateVerdict {
let mut maps: Vec<BTreeMap<String, String>> = Vec::with_capacity(per_run_hashes.len());
for run in per_run_hashes {
let Some(info) = run.get(name) else {
return AggregateVerdict::FailClosed(format!(
"aggregate {name} missing from a run — cannot reconstruct members; \
treated as real drift"
));
};
let Some(full) = info.full.as_deref() else {
return AggregateVerdict::FailClosed(format!(
"aggregate {name} full bytes not captured — cannot reconstruct members; \
treated as real drift"
));
};
match kind.members_by_unit(full) {
Ok(m) => maps.push(m),
Err(e) => {
return AggregateVerdict::FailClosed(format!(
"aggregate {name} failed to parse ({e:#}); treated as real drift"
));
}
}
}
let n = maps.len();
let mut all_keys: BTreeSet<&String> = BTreeSet::new();
for m in &maps {
all_keys.extend(m.keys());
}
let mut differing_members: BTreeSet<String> = BTreeSet::new();
for key in all_keys {
let present = maps.iter().filter(|m| m.contains_key(key)).count();
if present < n
&& let Some(member) = maps.iter().find_map(|m| m.get(key))
{
differing_members.insert(member.clone());
}
}
if differing_members.is_empty() {
return AggregateVerdict::FailClosed(format!(
"aggregate {name} bytes drifted but no member unit changed \
(structural / ordering drift); treated as real drift"
));
}
let mut unexcused: Vec<String> = Vec::new();
for member in &differing_members {
match self.member_excused(member, per_run_hashes, combined_markers, visited) {
Ok(true) => {}
Ok(false) => unexcused.push(member.clone()),
Err(reason) => return AggregateVerdict::FailClosed(reason),
}
}
if unexcused.is_empty() {
AggregateVerdict::Excused(format!(
"aggregate of derived rows: every differing member ({}) is allow-listed \
non-deterministic; each member is drift-checked independently",
differing_members
.iter()
.cloned()
.collect::<Vec<_>>()
.join(", ")
))
} else {
AggregateVerdict::Regression(unexcused)
}
}
fn member_excused(
&self,
member: &str,
per_run_hashes: &[BTreeMap<String, ArtifactInfo>],
combined_markers: &BTreeSet<String>,
visited: &mut BTreeSet<String>,
) -> Result<bool, String> {
if self.resolve_allow_reason(member).is_some() {
return Ok(true);
}
let Some(kind) = self.aggregate_kind_for_name(member, combined_markers) else {
return Ok(false);
};
let agg_name = per_run_hashes
.last()
.and_then(|run| run.keys().find(|k| basename(k) == member).cloned());
let Some(agg_name) = agg_name else {
return Err(format!(
"nested aggregate member {member} could not be located among produced \
artifacts — cannot verify its members; treated as real drift"
));
};
if !visited.insert(agg_name.clone()) {
return Err(format!(
"aggregate membership cycle detected at {agg_name}; treated as real drift"
));
}
let verdict = self.evaluate_aggregate_inner(
kind.as_ref(),
&agg_name,
per_run_hashes,
combined_markers,
visited,
);
match verdict {
AggregateVerdict::Excused(_) => Ok(true),
AggregateVerdict::Regression(_) => Ok(false),
AggregateVerdict::FailClosed(reason) => Err(reason),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Classification {
Aggregate,
Sidecar,
Primary,
Unclassified,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum AggregateVerdict {
Excused(String),
Regression(Vec<String>),
FailClosed(String),
}
fn strip_sidecar_suffix(name: &str) -> Option<&str> {
name.strip_suffix(".sha256")
.or_else(|| name.strip_suffix(".sig"))
}
fn basename(name: &str) -> &str {
name.rsplit(['/', '\\']).next().unwrap_or(name)
}
#[cfg(test)]
mod tests {
use super::artifacts::{
HEAD_SAMPLE_BYTES, TAIL_SAMPLE_BYTES, infer_stage_from_path, should_capture_full,
};
use super::*;
use anodizer_core::AllowListEntry;
#[test]
fn stage_id_from_token_round_trips_every_variant() {
for s in StageId::iter() {
assert_eq!(
StageId::from_token(s.as_str()),
Some(s),
"from_token is not the inverse of as_str for {s:?}"
);
}
assert_eq!(StageId::from_token("not-a-stage"), None);
assert_eq!(StageId::from_token(""), None);
}
#[test]
fn require_c_toolchain_errors_on_msvc_target_without_clang_cl() {
let targets = vec!["x86_64-pc-windows-msvc".to_string()];
let err = require_c_toolchain(&targets, false, |_| false).unwrap_err();
assert!(
err.to_string().contains("clang-cl"),
"error must name clang-cl as the missing tool: {err}"
);
}
#[test]
fn require_c_toolchain_ok_on_msvc_target_with_clang_cl_present() {
let targets = vec!["aarch64-pc-windows-msvc".to_string()];
require_c_toolchain(&targets, false, |_| true).unwrap();
}
#[test]
fn require_c_toolchain_ok_on_non_msvc_target_without_clang_cl() {
let targets = vec!["x86_64-unknown-linux-gnu".to_string()];
require_c_toolchain(&targets, false, |_| false).unwrap();
}
#[test]
fn require_c_toolchain_errors_on_empty_targets_when_host_is_msvc() {
require_c_toolchain(&[], true, |_| false).unwrap_err();
}
#[test]
fn require_c_toolchain_ok_on_empty_targets_when_host_is_not_msvc() {
require_c_toolchain(&[], false, |_| false).unwrap();
}
#[test]
fn require_c_toolchain_ok_on_mixed_targets_with_clang_cl_present() {
let targets = vec![
"x86_64-pc-windows-msvc".to_string(),
"x86_64-unknown-linux-gnu".to_string(),
];
require_c_toolchain(&targets, false, |_| true).unwrap();
}
fn empty_harness() -> Harness {
Harness {
repo_root: PathBuf::from("/tmp/unused"),
commit: "deadbeef".into(),
stages: vec![StageId::Archive, StageId::Checksum],
explicit_stages: vec![StageId::Archive, StageId::Checksum],
require_tools: false,
runs: 2,
sde: 1_715_000_000,
allowlist: AllowList::default(),
report_path: PathBuf::from("/tmp/unused/report.json"),
inject_drift: None,
targets: None,
preserve_dist: None,
version_hint: String::new(),
child_snapshot: true,
docker_backend_hint: None,
docker_configs: Vec::new(),
docker_declared: false,
crate_name: None,
verbosity: Verbosity::Normal,
config_tools: BTreeMap::new(),
disk_abs_floor_bytes: anodizer_core::disk::DEFAULT_ABS_FLOOR_BYTES,
disk_safety_factor: anodizer_core::disk::DEFAULT_SAFETY_FACTOR,
}
}
fn harness_with_allow(patterns: &[&str]) -> Harness {
let mut h = empty_harness();
h.allowlist = AllowList {
compile_time: patterns
.iter()
.map(|p| AllowListEntry {
artifact: (*p).to_string(),
reason: format!("test: {p} is intrinsically non-deterministic"),
})
.collect(),
runtime: Vec::new(),
};
h
}
fn run_with_files(
h: &Harness,
runs: Vec<Vec<(&str, &[u8])>>,
) -> Vec<BTreeMap<String, ArtifactInfo>> {
let _ = h;
runs.into_iter()
.map(|files| {
let mut map = BTreeMap::new();
for (name, bytes) in files {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(bytes);
let digest = format!("sha256:{:x}", hasher.finalize());
let head_len = bytes.len().min(HEAD_SAMPLE_BYTES);
let tail_sample = if bytes.len() > HEAD_SAMPLE_BYTES + TAIL_SAMPLE_BYTES {
bytes[bytes.len() - TAIL_SAMPLE_BYTES..].to_vec()
} else {
Vec::new()
};
let full = if should_capture_full(name, bytes) {
Some(bytes.to_vec())
} else {
None
};
map.insert(
name.into(),
ArtifactInfo {
hash: digest,
size_bytes: bytes.len() as u64,
relative_path: format!("dist/{}", name),
stage: infer_stage_from_path(name),
head_sample: bytes[..head_len].to_vec(),
tail_sample,
full,
},
);
}
map
})
.collect()
}
#[test]
fn harness_report_shape_serializes_correctly() {
let h = empty_harness();
let runs = run_with_files(
&h,
vec![
vec![("anodizer_0.2.1.tar.gz", b"hello")],
vec![("anodizer_0.2.1.tar.gz", b"hello")],
],
);
let report = h.build_report(runs);
assert_eq!(report.schema_version, 1);
assert_eq!(report.runs, 2);
assert_eq!(report.commit, "deadbeef");
assert_eq!(report.stages_under_test, vec!["archive", "checksum"]);
assert_eq!(report.drift_count, 0);
assert_eq!(report.artifacts.len(), 1);
assert!(report.artifacts[0].deterministic);
assert!(report.artifacts[0].hash.is_some());
assert!(report.artifacts[0].hashes.is_empty());
let s = serde_json::to_string_pretty(&report).unwrap();
let back: DeterminismReport = serde_json::from_str(&s).unwrap();
assert_eq!(back, report);
}
#[test]
fn harness_diffs_artifacts_by_sha256() {
let h = empty_harness();
let runs = run_with_files(
&h,
vec![
vec![("stable.tar.gz", b"hello"), ("drifting.tar.gz", b"first")],
vec![("stable.tar.gz", b"hello"), ("drifting.tar.gz", b"second")],
],
);
let report = h.build_report(runs);
assert_eq!(report.drift_count, 1);
assert_eq!(report.drift.len(), 1);
assert_eq!(report.drift[0].artifact, "drifting.tar.gz");
assert_eq!(report.drift[0].hashes.len(), 2);
assert_ne!(report.drift[0].hashes[0], report.drift[0].hashes[1]);
let summary = report.drift[0]
.differing_bytes_summary
.as_deref()
.expect("drift row must populate differing_bytes_summary");
assert!(
summary.contains("offset 0x0"),
"summary should point at byte 0 for diverging single-byte prefixes. got={summary}"
);
let stable = report
.artifacts
.iter()
.find(|a| a.name == "stable.tar.gz")
.unwrap();
let drifting = report
.artifacts
.iter()
.find(|a| a.name == "drifting.tar.gz")
.unwrap();
assert!(stable.deterministic);
assert!(!drifting.deterministic);
assert!(drifting.hash.is_none());
assert_eq!(drifting.hashes.len(), 2);
}
#[test]
fn aggregate_excused_when_only_allowlisted_member_drifts() {
let h = harness_with_allow(&["*.deb"]);
let run0 = b"hashA bar.tar.gz\ndeb000 app_1.0_amd64.deb\n" as &[u8];
let run1 = b"hashA bar.tar.gz\ndeb111 app_1.0_amd64.deb\n" as &[u8];
let runs = run_with_files(
&h,
vec![
vec![("app_checksums.txt", run0), ("bar.tar.gz", b"stable")],
vec![("app_checksums.txt", run1), ("bar.tar.gz", b"stable")],
],
);
let report = h.build_report(runs);
assert_eq!(
report.drift_count, 0,
"aggregate drift caused only by an allow-listed member must not fail"
);
let agg = report
.artifacts
.iter()
.find(|a| a.name == "app_checksums.txt")
.expect("checksums row present");
assert!(!agg.deterministic);
assert!(
agg.nondeterministic_reason
.as_deref()
.is_some_and(|r| r.contains("app_1.0_amd64.deb")),
"excuse must name the differing allow-listed member: {:?}",
agg.nondeterministic_reason
);
}
#[test]
fn aggregate_fails_when_gated_member_drifts_even_if_member_row_suppressed() {
let h = harness_with_allow(&["*.deb"]);
let run0 = b"t000 bar.tar.gz\ndeb000 app_1.0_amd64.deb\n" as &[u8];
let run1 = b"t111 bar.tar.gz\ndeb111 app_1.0_amd64.deb\n" as &[u8];
let runs = run_with_files(
&h,
vec![
vec![("app_checksums.txt", run0)],
vec![("app_checksums.txt", run1)],
],
);
let report = h.build_report(runs);
assert_eq!(
report.drift_count, 1,
"a gated member drifting inside the aggregate must surface as drift"
);
assert!(
report
.drift
.iter()
.any(|d| d.artifact.contains("bar.tar.gz")),
"the offending gated member must be named: {:?}",
report.drift
);
assert!(
!report.drift.iter().any(|d| d.artifact.contains(".deb")),
"allow-listed member must not be reported as a regression"
);
}
#[test]
fn aggregate_judges_additions_and_removals_by_member_status() {
let h = harness_with_allow(&["*.deb"]);
let add0 = b"a000 a.tar.gz\ndeb000 x_1.0_amd64.deb\n" as &[u8];
let add1 = b"a000 a.tar.gz\ndeb000 x_1.0_amd64.deb\nb000 b.tar.gz\n" as &[u8];
let runs = run_with_files(
&h,
vec![
vec![("c_checksums.txt", add0)],
vec![("c_checksums.txt", add1)],
],
);
let report = h.build_report(runs);
assert_eq!(report.drift_count, 1, "added gated member must fail");
assert!(report.drift.iter().any(|d| d.artifact.contains("b.tar.gz")));
let rem0 = b"a000 a.tar.gz\ndeb000 x_1.0_amd64.deb\n" as &[u8];
let rem1 = b"a000 a.tar.gz\n" as &[u8];
let runs = run_with_files(
&h,
vec![
vec![("c_checksums.txt", rem0)],
vec![("c_checksums.txt", rem1)],
],
);
let report = h.build_report(runs);
assert_eq!(
report.drift_count, 0,
"removing an allow-listed member must be excused"
);
}
#[test]
fn aggregate_fails_closed_on_unparseable_or_structural_drift() {
let h = harness_with_allow(&["*.deb"]);
let s0 = b"a a.tar.gz\nd x_1.0_amd64.deb\n" as &[u8];
let s1 = b"d x_1.0_amd64.deb\na a.tar.gz\nz z\n" as &[u8];
let runs = run_with_files(
&h,
vec![vec![("s_checksums.txt", s0)], vec![("s_checksums.txt", s1)]],
);
let report = h.build_report(runs);
assert_eq!(
report.drift_count, 1,
"an aggregate whose drift cannot be attributed must fail closed"
);
}
#[test]
fn artifacts_manifest_transitive_rule() {
let h = harness_with_allow(&["*.deb"]);
let gated0 = br#"[
{"kind":"archive","path":"./dist/a.tar.gz","name":"a.tar.gz","metadata":{"sha256":"aaaa"}},
{"kind":"linux_package","path":"./dist/a_1.0_amd64.deb","name":"a_1.0_amd64.deb","metadata":{"sha256":"dddd"}}
]"# as &[u8];
let gated1 = br#"[
{"kind":"archive","path":"./dist/a.tar.gz","name":"a.tar.gz","metadata":{"sha256":"bbbb"}},
{"kind":"linux_package","path":"./dist/a_1.0_amd64.deb","name":"a_1.0_amd64.deb","metadata":{"sha256":"dddd"}}
]"#;
let runs = run_with_files(
&h,
vec![
vec![("artifacts.json", gated0)],
vec![("artifacts.json", gated1)],
],
);
let report = h.build_report(runs);
assert_eq!(
report.drift_count, 1,
"gated archive digest drift must fail"
);
assert!(report.drift.iter().any(|d| d.artifact.contains("a.tar.gz")));
let deb1 = br#"[
{"kind":"archive","path":"./dist/a.tar.gz","name":"a.tar.gz","metadata":{"sha256":"aaaa"}},
{"kind":"linux_package","path":"./dist/a_1.0_amd64.deb","name":"a_1.0_amd64.deb","metadata":{"sha256":"eeee"}}
]"#;
let runs = run_with_files(
&h,
vec![
vec![("artifacts.json", gated0)],
vec![("artifacts.json", deb1)],
],
);
let report = h.build_report(runs);
assert_eq!(
report.drift_count, 0,
"deb-only digest drift must be excused"
);
}
#[test]
fn marker_named_combined_file_obeys_transitive_rule() {
let h = harness_with_allow(&["*.deb"]);
let manifest = br#"[
{"kind":"archive","path":"./dist/bar.tar.gz","name":"bar.tar.gz","metadata":{"sha256":"barbar"}},
{"kind":"linux_package","path":"./dist/app_1.0_amd64.deb","name":"app_1.0_amd64.deb","metadata":{"sha256":"debdeb"}},
{"kind":"checksum","path":"./dist/SHA512SUMS","name":"SHA512SUMS","metadata":{"combined":"true"}}
]"# as &[u8];
assert!(anodizer_core::determinism::aggregate_kind_for("SHA512SUMS").is_none());
let sums0 = b"barbar bar.tar.gz\ndeb000 app_1.0_amd64.deb\n" as &[u8];
let sums1 = b"barbar bar.tar.gz\ndeb111 app_1.0_amd64.deb\n" as &[u8];
let runs = run_with_files(
&h,
vec![
vec![
("artifacts.json", manifest),
("SHA512SUMS", sums0),
("bar.tar.gz", b"stable"),
],
vec![
("artifacts.json", manifest),
("SHA512SUMS", sums1),
("bar.tar.gz", b"stable"),
],
],
);
let report = h.build_report(runs);
assert_eq!(
report.drift_count, 0,
"marker-named combined file drift from an allow-listed member must be excused: {:?}",
report.drift
);
let agg = report
.artifacts
.iter()
.find(|a| a.name == "SHA512SUMS")
.expect("SHA512SUMS classified as an aggregate row");
assert!(!agg.deterministic);
assert!(
agg.nondeterministic_reason
.as_deref()
.is_some_and(|r| r.contains("app_1.0_amd64.deb")),
"excuse must name the drifting allow-listed member: {:?}",
agg.nondeterministic_reason
);
let g0 = b"bar000 bar.tar.gz\ndeb000 app_1.0_amd64.deb\n" as &[u8];
let g1 = b"bar111 bar.tar.gz\ndeb000 app_1.0_amd64.deb\n" as &[u8];
let runs = run_with_files(
&h,
vec![
vec![
("artifacts.json", manifest),
("SHA512SUMS", g0),
("bar.tar.gz", b"stable"),
],
vec![
("artifacts.json", manifest),
("SHA512SUMS", g1),
("bar.tar.gz", b"stable"),
],
],
);
let report = h.build_report(runs);
assert_eq!(
report.drift_count, 1,
"a gated member drifting inside the renamed combined file must fail"
);
assert!(
report
.drift
.iter()
.any(|d| d.artifact.contains("bar.tar.gz")),
"the gated member must be named: {:?}",
report.drift
);
}
#[test]
fn artifacts_manifest_recut_excuses_only_nondeterministic_members() {
let mut h = empty_harness();
h.allowlist = AllowList {
compile_time: vec![
AllowListEntry {
artifact: "*.cdx.json".into(),
reason: "CycloneDX SBOM carries a random serial UUID".into(),
},
AllowListEntry {
artifact: "*.deb".into(),
reason: "GPG-signed nfpm deb".into(),
},
],
runtime: vec![
AllowListEntry {
artifact: "*.cosign.bundle".into(),
reason: "cosign ECDSA random nonce".into(),
},
AllowListEntry {
artifact: "*.sig".into(),
reason: "cosign detached signature".into(),
},
],
};
let manifest = |arch: &str, sbom: &str, bundle: &str, sig: &str| {
format!(
r#"[
{{"kind":"archive","path":"./dist/app.tar.gz","name":"app.tar.gz","metadata":{{"sha256":"{arch}"}}}},
{{"kind":"sbom","path":"./dist/app.cdx.json","name":"app.cdx.json","metadata":{{"sha256":"{sbom}"}}}},
{{"kind":"signature","path":"./dist/app.tar.gz.cosign.bundle","name":"app.tar.gz.cosign.bundle","metadata":{{"sha256":"{bundle}"}}}},
{{"kind":"signature","path":"./dist/app.tar.gz.sig","name":"app.tar.gz.sig","metadata":{{"sha256":"{sig}"}}}},
{{"kind":"checksum","path":"./dist/checksums.txt","name":"checksums.txt","metadata":{{"combined":"true"}}}},
{{"kind":"metadata","path":"./dist/metadata.json","name":"metadata.json","metadata":{{}}}},
{{"kind":"uploadable_file","path":"./dist/install.sh","name":"install.sh","metadata":{{"sha256":"inst"}}}}
]"#
)
.into_bytes()
};
let checksums = b"arch app.tar.gz\n" as &[u8];
let files = |m: &[u8], sbom: &[u8], bundle: &[u8], sig: &[u8]| -> Vec<(String, Vec<u8>)> {
vec![
("artifacts.json".into(), m.to_vec()),
("app.tar.gz".into(), b"archive-stable".to_vec()),
("app.cdx.json".into(), sbom.to_vec()),
("app.tar.gz.cosign.bundle".into(), bundle.to_vec()),
("app.tar.gz.sig".into(), sig.to_vec()),
("checksums.txt".into(), checksums.to_vec()),
("metadata.json".into(), b"meta-stable".to_vec()),
("install.sh".into(), b"#!/bin/sh\n".to_vec()),
]
};
fn borrow(v: &[(String, Vec<u8>)]) -> Vec<(&str, &[u8])> {
v.iter().map(|(n, b)| (n.as_str(), b.as_slice())).collect()
}
let r0 = files(
&manifest("ARCH", "SB0", "BUN0", "SIG0"),
b"sbom-0",
b"bundle-0",
b"sig-0",
);
let r1 = files(
&manifest("ARCH", "SB1", "BUN1", "SIG1"),
b"sbom-1",
b"bundle-1",
b"sig-1",
);
let runs = run_with_files(&h, vec![borrow(&r0), borrow(&r1)]);
let report = h.build_report(runs);
assert_eq!(
report.drift_count, 0,
"a recut that only moves SBOM/cosign-bundle/sig must be fully excused: {:?}",
report.drift
);
let g0 = files(
&manifest("ARCH0", "SB0", "BUN0", "SIG0"),
b"sbom-0",
b"bundle-0",
b"sig-0",
);
let mut g1 = files(
&manifest("ARCH1", "SB0", "BUN0", "SIG0"),
b"sbom-0",
b"bundle-0",
b"sig-0",
);
for entry in &mut g1 {
if entry.0 == "app.tar.gz" {
entry.1 = b"archive-DRIFTED".to_vec();
}
}
let runs = run_with_files(&h, vec![borrow(&g0), borrow(&g1)]);
let report = h.build_report(runs);
assert!(
report.drift_count >= 1,
"a gated archive regression must surface"
);
assert!(
report
.drift
.iter()
.any(|d| d.artifact.contains("app.tar.gz")),
"the gated archive must be named in drift: {:?}",
report.drift
);
}
#[test]
fn nested_aggregate_recursion_judges_inner_members() {
let h = harness_with_allow(&["*.cdx.json"]);
let manifest = |size: u32| {
format!(
r#"[
{{"kind":"archive","path":"./dist/app.tar.gz","name":"app.tar.gz","metadata":{{"sha256":"AAAA"}}}},
{{"kind":"sbom","path":"./dist/app.cdx.json","name":"app.cdx.json","metadata":{{"sha256":"SB{size}"}}}},
{{"kind":"checksum","path":"./dist/checksums.txt","name":"checksums.txt","metadata":{{"combined":"true"}},"size":{size}}}
]"#
)
.into_bytes()
};
let ck0 = b"arch app.tar.gz\nsbom0 app.cdx.json\n" as &[u8];
let ck1 = b"arch app.tar.gz\nsbom1 app.cdx.json\n" as &[u8];
let m0 = manifest(100);
let m1 = manifest(101);
let runs = run_with_files(
&h,
vec![
vec![("artifacts.json", m0.as_slice()), ("checksums.txt", ck0)],
vec![("artifacts.json", m1.as_slice()), ("checksums.txt", ck1)],
],
);
let report = h.build_report(runs);
assert_eq!(
report.drift_count, 0,
"nested aggregate excused when inner drift is allow-listed: {:?}",
report.drift
);
let bad0 = b"arch0 app.tar.gz\nsbom0 app.cdx.json\n" as &[u8];
let bad1 = b"arch1 app.tar.gz\nsbom0 app.cdx.json\n" as &[u8];
let runs = run_with_files(
&h,
vec![
vec![("artifacts.json", m0.as_slice()), ("checksums.txt", bad0)],
vec![("artifacts.json", m1.as_slice()), ("checksums.txt", bad1)],
],
);
let report = h.build_report(runs);
assert!(
report.drift_count >= 1,
"nested aggregate must fail when an inner gated member drifts"
);
assert!(
report
.drift
.iter()
.any(|d| d.artifact.contains("checksums.txt") || d.artifact.contains("app.tar.gz")),
"the failing nested member chain must be named: {:?}",
report.drift
);
}
#[test]
fn unclassified_gates_on_byte_drift_not_on_classification() {
let h = harness_with_allow(&["*.flatpak"]);
let manifest = br#"[
{"kind":"archive","path":"./dist/foo.tar.gz","name":"foo.tar.gz","metadata":{"sha256":"aaaa"}},
{"kind":"uploadable_file","path":"./dist/install.sh","name":"install.sh","metadata":{"sha256":"bbbb"}},
{"kind":"metadata","path":"./dist/metadata.json","name":"metadata.json","metadata":{}}
]"# as &[u8];
let checksums = b"aaaa foo.tar.gz\n" as &[u8];
let files: Vec<(&str, &[u8])> = vec![
("foo.tar.gz", b"archive-bytes"), ("foo.tar.gz.sig", b"sig-bytes"), ("app_1.0_amd64.deb", b"deb-bytes"), ("app_1.0_amd64.flatpak", b"fp-bytes"), ("anodizer.1", b"man-bytes"), ("install.sh", b"sh-bytes"), ("metadata.json", b"meta-bytes"), ("app_checksums.txt", checksums), ("artifacts.json", manifest), ];
let runs = run_with_files(&h, vec![files.clone(), files]);
let report = h.build_report(runs);
assert_eq!(
report.drift_count, 0,
"a fully-classified, byte-stable run must not fail. drift={:?}",
report.drift
);
assert!(
!report.drift.iter().any(|d| d
.differing_bytes_summary
.as_deref()
.is_some_and(|s| s.contains("unclassified"))),
"no file should be unclassified: {:?}",
report.drift
);
let stable: Vec<(&str, &[u8])> = vec![("mystery.xyz", b"same")];
let report = h.build_report(run_with_files(&h, vec![stable.clone(), stable]));
assert_eq!(
report.drift_count, 0,
"a byte-stable unclassified file must NOT fail: {:?}",
report.drift
);
let drifting: Vec<Vec<(&str, &[u8])>> =
vec![vec![("mystery.xyz", b"one")], vec![("mystery.xyz", b"two")]];
let report = h.build_report(run_with_files(&h, drifting));
assert_eq!(
report.drift_count, 1,
"a drifting unclassified file must hard-fail: {:?}",
report.drift
);
assert!(
report.drift[0]
.differing_bytes_summary
.as_deref()
.is_some_and(|s| s.contains("unclassified")),
"the drift reason must flag the unclassified file: {:?}",
report.drift
);
}
#[test]
fn harness_excludes_allowlisted_artifacts_from_drift() {
let mut h = empty_harness();
h.allowlist.compile_time.push(AllowListEntry {
artifact: "*.flatpak".into(),
reason: "flatpak build-bundle OSTree commit metadata not byte-stable".into(),
});
let runs = run_with_files(
&h,
vec![
vec![("anodizer_0.2.1_linux_amd64.flatpak", b"flatpak-bytes-A")],
vec![("anodizer_0.2.1_linux_amd64.flatpak", b"flatpak-bytes-B")],
],
);
let report = h.build_report(runs);
assert_eq!(
report.drift_count, 0,
"allowlisted artifact must not bump drift_count"
);
let row = &report.artifacts[0];
assert_eq!(row.name, "anodizer_0.2.1_linux_amd64.flatpak");
assert!(!row.deterministic);
assert_eq!(
row.nondeterministic_reason.as_deref(),
Some("flatpak build-bundle OSTree commit metadata not byte-stable")
);
assert_eq!(row.hashes.len(), 2);
}
#[test]
fn harness_treats_missing_artifact_in_one_run_as_drift() {
let h = empty_harness();
let runs = run_with_files(&h, vec![vec![("only-in-run-1.tar.gz", b"present")], vec![]]);
let report = h.build_report(runs);
assert_eq!(report.drift_count, 1);
assert_eq!(report.drift[0].artifact, "only-in-run-1.tar.gz");
assert!(report.drift[0].hashes.iter().any(|h| h == "<missing>"));
}
#[test]
fn matches_artifact_pattern_handles_glob_and_exact() {
assert!(matches_artifact_pattern("*.crate", "foo.crate"));
assert!(!matches_artifact_pattern("*.crate", "foo.tar.gz"));
assert!(matches_artifact_pattern("exact.bin", "exact.bin"));
assert!(!matches_artifact_pattern("exact.bin", "other.bin"));
}
#[test]
fn stage_id_round_trips_to_string() {
assert_eq!(StageId::Build.as_str(), "build");
assert_eq!(StageId::Archive.as_str(), "archive");
assert_eq!(StageId::Sbom.as_str(), "sbom");
assert_eq!(StageId::Sign.as_str(), "sign");
assert_eq!(StageId::Checksum.as_str(), "checksum");
}
#[test]
fn harness_extra_skip_with_default_stages_includes_nfpm() {
let stages = vec![
StageId::Build,
StageId::Archive,
StageId::Sbom,
StageId::Sign,
StageId::Checksum,
];
let extra = compute_extra_skip(&stages);
for name in [
"nfpm",
"nsis",
"msi",
"dmg",
"pkg",
"snapcraft",
"source",
"flatpak",
"appbundle",
"srpm",
"upx",
"makeself",
] {
assert!(
extra.iter().any(|s| s == name),
"compute_extra_skip(default-stages) missing `{name}`: {extra:?}"
);
}
assert!(
!extra.iter().any(|s| s == "notarize"),
"notarize must not appear in the complement set: {extra:?}"
);
}
#[test]
fn harness_extra_skip_omits_preserve_set() {
let stages = vec![StageId::Build, StageId::Archive];
let extra = compute_extra_skip(&stages);
for name in PRESERVE_SET {
assert!(
!extra.iter().any(|s| s == name),
"compute_extra_skip emitted PRESERVE_SET stage `{name}`: {extra:?}"
);
}
}
#[test]
fn harness_extra_skip_includes_changelog() {
let stages = vec![StageId::Build, StageId::Archive];
let extra = compute_extra_skip(&stages);
assert!(
extra.iter().any(|s| s == "changelog"),
"compute_extra_skip missing `changelog`: {extra:?}"
);
}
#[test]
fn harness_extra_skip_omits_requested_stages() {
let stages = vec![StageId::Build, StageId::Archive, StageId::Sign];
let extra = compute_extra_skip(&stages);
for name in ["build", "archive", "sign"] {
assert!(
!extra.iter().any(|s| s == name),
"compute_extra_skip dropped requested stage `{name}`: {extra:?}"
);
}
}
#[test]
fn harness_extra_skip_retains_build_for_binary_consuming_subset() {
for stages in [
vec![StageId::Appimage, StageId::Flatpak],
vec![StageId::Flatpak],
vec![StageId::Nfpm],
vec![StageId::Archive],
] {
let extra = compute_extra_skip(&stages);
assert!(
!extra.iter().any(|s| s == "build"),
"compute_extra_skip skipped `build` for binary-consuming subset {stages:?}: {extra:?}"
);
}
}
#[test]
fn harness_extra_skip_skips_build_for_source_only_subset() {
for stages in [
vec![StageId::Source],
vec![StageId::CargoPackage],
vec![StageId::Srpm],
] {
let extra = compute_extra_skip(&stages);
assert!(
extra.iter().any(|s| s == "build"),
"compute_extra_skip kept `build` for source-only subset {stages:?}: {extra:?}"
);
}
}
#[test]
fn harness_extra_skip_excludes_side_effect_stages() {
use anodizer_core::determinism_runner::SIDE_EFFECT_STAGES;
let stages = vec![StageId::Build];
let extra = compute_extra_skip(&stages);
for &name in SIDE_EFFECT_STAGES.iter() {
assert!(
!extra.iter().any(|s| s == name),
"compute_extra_skip double-listed side-effect stage `{name}`: {extra:?}"
);
}
}
#[test]
fn report_drift_count_matches_drift_array_len() {
let h = empty_harness();
let runs = run_with_files(
&h,
vec![
vec![("a.tar.gz", b"x"), ("b.tar.gz", b"y"), ("c.tar.gz", b"z")],
vec![
("a.tar.gz", b"x"),
("b.tar.gz", b"y-different"),
("c.tar.gz", b"z-different"),
],
],
);
let report = h.build_report(runs);
assert_eq!(report.drift.len() as u32, report.drift_count);
assert_eq!(report.drift_count, 2);
}
#[test]
fn docker_stage_no_config_is_ok_even_when_explicit() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("Dockerfile"), "FROM scratch\n").unwrap();
let h = empty_harness();
assert!(h.docker_configs.is_empty());
let env = HashMap::new();
assert!(
h.run_docker_stage(tmp.path(), &env, true).is_ok(),
"no dockers_v2 config must be a harmless no-op regardless of intent"
);
}
#[test]
fn docker_stage_declared_but_all_skipped_warns_not_errors_even_when_explicit() {
let tmp = tempfile::TempDir::new().unwrap();
let mut h = empty_harness();
h.docker_declared = true; h.docker_configs = Vec::new(); let env = HashMap::new();
assert!(
h.run_docker_stage(tmp.path(), &env, true).is_ok(),
"declared-but-all-skipped docker must warn-and-skip, not error, even under an \
explicit request (production parity)"
);
assert!(
h.run_docker_stage(tmp.path(), &env, false).is_ok(),
"declared-but-all-skipped docker must warn-and-skip on a host-default run too"
);
}
#[test]
fn docker_stage_podman_explicit_request_is_hard_error() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("Dockerfile"), "FROM scratch\n").unwrap();
let mut h = empty_harness();
h.docker_backend_hint = Some("podman".into());
h.docker_configs = vec![ResolvedDockerConfig {
dockerfile: "Dockerfile".into(),
extra_files: Vec::new(),
build_args: Vec::new(),
}];
let env = HashMap::new();
let err = h
.run_docker_stage(tmp.path(), &env, true)
.expect_err("explicit docker request under podman must fail the run, not skip");
let msg = err.to_string();
assert!(
msg.contains("podman") && msg.contains("Refusing"),
"error must explain the false-coverage refusal: {msg}"
);
}
#[test]
fn installer_explicit_request_missing_tool_is_hard_error() {
let mut h = empty_harness();
h.stages = vec![StageId::Build, StageId::Nsis];
h.explicit_stages = h.stages.clone();
let err = h
.gate_installer_stages(&h.stages.clone(), |_tool| false)
.expect_err(
"explicit installer request with a missing tool must fail the run, not skip",
);
let msg = err.to_string();
assert!(
msg.contains("nsis") && msg.contains("makensis"),
"error must name the missing stage and its tool: {msg}"
);
assert!(
msg.contains("--stages"),
"error must tell the operator how to opt out: {msg}"
);
}
#[test]
fn installer_non_explicit_missing_tool_warns_and_drops() {
let h = empty_harness();
let effective = vec![StageId::Build, StageId::Nsis];
let available = h
.gate_installer_stages(&effective, |_tool| false)
.expect("non-explicit missing tool must warn-and-drop, not error");
assert_eq!(
available,
vec![StageId::Build],
"missing-tool installer must be dropped; non-installer stages pass through"
);
}
#[test]
fn require_tools_hard_fails_host_default_missing_tool() {
let mut h = empty_harness();
h.explicit_stages = Vec::new();
h.require_tools = true;
let effective = vec![StageId::Build, StageId::Nsis];
let err = h
.gate_installer_stages(&effective, |_tool| false)
.expect_err("--require-tools must hard-fail a host-default missing tool");
let msg = err.to_string();
assert!(
msg.contains("nsis") && msg.contains("makensis"),
"error must name the missing host-default stage and its tool: {msg}"
);
}
#[test]
fn require_tools_keeps_host_default_when_tool_present() {
let mut h = empty_harness();
h.explicit_stages = Vec::new();
h.require_tools = true;
let effective = vec![StageId::Build, StageId::Nsis];
let available = h
.gate_installer_stages(&effective, |_tool| true)
.expect("present tool must pass even under --require-tools");
assert_eq!(available, vec![StageId::Build, StageId::Nsis]);
}
#[test]
fn require_tools_hard_fails_host_default_missing_upx() {
let mut h = empty_harness();
h.explicit_stages = Vec::new();
h.require_tools = true;
h.config_tools.insert(StageId::Upx, vec!["upx".to_string()]);
let effective = vec![StageId::Build, StageId::Upx];
let err = h
.gate_installer_stages(&effective, |_tool| false)
.expect_err("--require-tools must hard-fail a host-default missing upx");
let msg = err.to_string();
assert!(
msg.contains("upx"),
"error must name the missing upx stage and its tool: {msg}"
);
}
#[test]
fn require_tools_keeps_host_default_upx_when_tool_present() {
let mut h = empty_harness();
h.explicit_stages = Vec::new();
h.require_tools = true;
h.config_tools.insert(StageId::Upx, vec!["upx".to_string()]);
let effective = vec![StageId::Build, StageId::Upx];
let available = h
.gate_installer_stages(&effective, |_tool| true)
.expect("present upx must pass even under --require-tools");
assert_eq!(available, vec![StageId::Build, StageId::Upx]);
}
#[test]
fn dev_mode_warn_skips_host_default_upx_when_tool_absent() {
let mut h = empty_harness();
h.explicit_stages = Vec::new();
h.require_tools = false;
h.config_tools.insert(StageId::Upx, vec!["upx".to_string()]);
let effective = vec![StageId::Build, StageId::Upx];
let available = h
.gate_installer_stages(&effective, |_tool| false)
.expect("dev-mode host-default missing upx must warn-and-drop, not error");
assert_eq!(
available,
vec![StageId::Build],
"missing-tool upx must be dropped in dev mode; non-gated stages pass through"
);
}
#[test]
fn msi_v3_gate_passes_when_candle_and_light_present() {
let mut h = empty_harness();
h.stages = vec![StageId::Build, StageId::Msi];
h.config_tools.insert(
StageId::Msi,
vec!["candle".to_string(), "light".to_string()],
);
let available = h
.gate_installer_stages(&h.stages.clone(), |tool| matches!(tool, "candle" | "light"))
.expect("v3 msi with candle+light present must pass the gate");
assert_eq!(
available,
vec![StageId::Build, StageId::Msi],
"msi must stay in the effective set when its resolved tools are present"
);
}
#[test]
fn msi_v3_gate_hard_fails_when_a_resolved_tool_absent() {
let mut h = empty_harness();
h.stages = vec![StageId::Build, StageId::Msi];
h.explicit_stages = h.stages.clone();
h.config_tools.insert(
StageId::Msi,
vec!["candle".to_string(), "light".to_string()],
);
let err = h
.gate_installer_stages(&h.stages.clone(), |tool| tool == "candle")
.expect_err("v3 msi missing `light` must hard-fail the run");
let msg = err.to_string();
assert!(
msg.contains("msi") && msg.contains("light"),
"error must name msi and the first missing tool: {msg}"
);
}
#[test]
fn docker_context_staging_lays_out_os_arch_bin_and_dockerfile() {
let tmp = tempfile::TempDir::new().unwrap();
let worktree = tmp.path();
for triple in ["x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"] {
let release = worktree
.join(".det-tmp")
.join("target")
.join(triple)
.join("release");
std::fs::create_dir_all(&release).unwrap();
std::fs::write(release.join("anodizer"), b"fake-binary").unwrap();
}
let host_release = worktree.join(".det-tmp").join("target").join("release");
std::fs::create_dir_all(&host_release).unwrap();
std::fs::write(host_release.join("anodizer"), b"host-byproduct").unwrap();
std::fs::write(worktree.join("Dockerfile"), "FROM scratch\nCOPY x x\n").unwrap();
let cfg = ResolvedDockerConfig {
dockerfile: "Dockerfile".into(),
extra_files: Vec::new(),
build_args: Vec::new(),
};
let context_dir = worktree.join(".det-tmp").join("docker-context");
let log = StageLogger::new("test", Verbosity::Quiet);
let staged = stage_docker_context(worktree, &context_dir, &cfg, &log).unwrap();
assert_eq!(staged, 2, "only per-triple binaries should be staged");
assert!(
context_dir
.join("linux")
.join("amd64")
.join("anodizer")
.is_file(),
"amd64 binary must land at <context>/linux/amd64/anodizer"
);
assert!(
context_dir
.join("linux")
.join("arm64")
.join("anodizer")
.is_file(),
"arm64 binary must land at <context>/linux/arm64/anodizer"
);
assert!(
context_dir.join("Dockerfile").is_file(),
"configured dockerfile must be copied to the staging root"
);
std::fs::write(context_dir.join("stale.txt"), b"old").unwrap();
let staged2 = stage_docker_context(worktree, &context_dir, &cfg, &log).unwrap();
assert_eq!(staged2, 2);
assert!(
!context_dir.join("stale.txt").exists(),
"re-run must wipe the prior staging dir so no bytes carry over"
);
}
#[test]
fn docker_context_staging_thin_dockerfile_and_extra_files() {
let tmp = tempfile::TempDir::new().unwrap();
let worktree = tmp.path();
let release = worktree
.join(".det-tmp")
.join("target")
.join("x86_64-unknown-linux-gnu")
.join("release");
std::fs::create_dir_all(&release).unwrap();
std::fs::write(release.join("cfgd"), b"fake-binary").unwrap();
std::fs::write(
worktree.join("Dockerfile"),
"FROM rust\nCOPY Cargo.lock .\n",
)
.unwrap();
std::fs::write(
worktree.join("Dockerfile.agent.release"),
"FROM debian\nARG TARGETOS=linux\nARG TARGETARCH\n\
COPY ${TARGETOS}/${TARGETARCH}/cfgd /usr/local/bin/cfgd\n\
COPY entrypoint.sh /entrypoint.sh\n",
)
.unwrap();
std::fs::write(worktree.join("entrypoint.sh"), b"#!/bin/sh\n").unwrap();
let cfg = ResolvedDockerConfig {
dockerfile: "Dockerfile.agent.release".into(),
extra_files: vec!["entrypoint.sh".into()],
build_args: vec![("VERSION".into(), "1.2.3".into())],
};
let context_dir = worktree.join(".det-tmp").join("docker-context-0");
let log = StageLogger::new("test", Verbosity::Quiet);
let staged = stage_docker_context(worktree, &context_dir, &cfg, &log).unwrap();
assert_eq!(staged, 1, "the single per-triple binary must be staged");
assert!(
context_dir
.join("linux")
.join("amd64")
.join("cfgd")
.is_file(),
"binary must land at <context>/linux/amd64/cfgd"
);
let staged_dockerfile = std::fs::read_to_string(context_dir.join("Dockerfile")).unwrap();
assert!(
staged_dockerfile.contains("COPY ${TARGETOS}/${TARGETARCH}/cfgd"),
"the configured thin dockerfile must be staged, not the fat repo-root one: \
{staged_dockerfile}"
);
assert!(
!staged_dockerfile.contains("COPY Cargo.lock"),
"the fat repo-root Dockerfile must never be staged: {staged_dockerfile}"
);
assert!(
context_dir.join("entrypoint.sh").is_file(),
"extra_files must be staged into the context"
);
}
#[test]
fn docker_stage_podman_auto_included_warns_and_skips() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("Dockerfile"), "FROM scratch\n").unwrap();
let mut h = empty_harness();
h.docker_backend_hint = Some("podman".into());
h.docker_configs = vec![ResolvedDockerConfig {
dockerfile: "Dockerfile".into(),
extra_files: Vec::new(),
build_args: Vec::new(),
}];
let env = HashMap::new();
assert!(
h.run_docker_stage(tmp.path(), &env, false).is_ok(),
"auto-included docker under podman must warn-and-skip, not error"
);
}
#[test]
fn headroom_guard_aborts_below_floor_with_actionable_message() {
const GIB: u64 = 1024 * 1024 * 1024;
let mut h = empty_harness();
h.disk_abs_floor_bytes = 45 * GIB;
let log = StageLogger::new("test", Verbosity::Quiet);
let vol = std::path::Path::new("/Volumes/scratch");
let err = h
.guard_run_headroom(&log, 0, vol, Some(30 * GIB), None)
.expect_err("below-floor free space must abort the run");
let msg = err.to_string();
assert!(msg.contains("determinism run 1"), "1-based run: {msg}");
assert!(
msg.contains(&format!("{}", 45 * GIB)),
"exact required: {msg}"
);
assert!(
msg.contains(&format!("{}", 30 * GIB)),
"exact available: {msg}"
);
assert!(msg.contains("/Volumes/scratch"), "volume: {msg}");
assert!(msg.contains("reclaim-disk"), "remedy hint: {msg}");
assert!(
msg.contains("absolute floor"),
"run-0 must state the floor basis, not a peak guarantee: {msg}"
);
}
#[test]
fn headroom_guard_proceeds_with_ample_space_and_gates_on_measured_peak() {
const GIB: u64 = 1024 * 1024 * 1024;
let mut h = empty_harness();
h.disk_abs_floor_bytes = 45 * GIB;
h.disk_safety_factor = 1.3;
let log = StageLogger::new("test", Verbosity::Quiet);
let vol = std::path::Path::new("/scratch");
assert!(
h.guard_run_headroom(&log, 0, vol, Some(60 * GIB), None)
.is_ok(),
"ample free space must proceed"
);
let prior_peak = Some(70 * GIB);
assert!(
h.guard_run_headroom(&log, 1, vol, Some(71 * GIB), prior_peak)
.is_err(),
"71 GiB free under a 91 GiB peak-projected requirement must abort"
);
assert!(
h.guard_run_headroom(&log, 1, vol, Some(95 * GIB), prior_peak)
.is_ok(),
"95 GiB free clears the 91 GiB peak-projected requirement"
);
}
#[test]
fn headroom_guard_unknown_free_space_is_noop() {
let h = empty_harness();
let log = StageLogger::new("test", Verbosity::Quiet);
let vol = std::path::Path::new("/scratch");
assert!(
h.guard_run_headroom(&log, 1, vol, None, None).is_ok(),
"unknown free space must proceed (no manufactured abort)"
);
}
}