use std::collections::BTreeMap;
use crate::determinism_harness::{Harness, ResolvedDockerConfig, StageId, installer_stages};
use anodizer_cli::CheckDeterminismArgs;
use anodizer_core::{
AllowList, AllowListEntry, DeterminismState,
git::{head_commit_hash_in, head_commit_timestamp_in, head_is_at_tag, resolve_snapshot_sde},
log::{StageLogger, Verbosity, render_error, render_note},
};
use anyhow::{Context, Result};
use strum::IntoEnumIterator;
pub fn run(args: CheckDeterminismArgs, verbose: bool, debug: bool, quiet: bool) -> Result<()> {
let verbosity = Verbosity::from_flags(quiet, verbose, debug);
let inject_drift = if std::env::var("ANODIZE_TEST_HARNESS").as_deref() == Ok("1") {
args.inject_drift.clone()
} else if args.inject_drift.is_some() {
anyhow::bail!("--inject-drift requires ANODIZE_TEST_HARNESS=1 (test-harness gated flag)");
} else {
None
};
if args.runs < 2 {
anyhow::bail!(
"check determinism: --runs must be >= 2 (a determinism check compares at least two \
rebuilds); got {}",
args.runs
);
}
let repo_root = std::env::current_dir().context("resolving repo root")?;
let sde = if args.snapshot {
resolve_snapshot_sde(&repo_root)?
} else {
head_commit_timestamp_in(&repo_root)?
};
let commit = head_commit_hash_in(&repo_root)?;
let repo_config = crate::pipeline::load_repo_config(&repo_root)
.ok()
.map(|mut c| {
anodizer_core::defaults_merge::apply_defaults(&mut c);
c
});
let stages = resolve_stages(args.stages.as_deref(), repo_config.as_ref())
.map_err(|e| anyhow::anyhow!(e))?;
let explicit_stages = if is_explicit_stage_selection(args.stages.as_deref()) {
stages.clone()
} else {
Vec::new()
};
let targets = parse_targets(args.targets.as_deref()).map_err(|e| anyhow::anyhow!(e))?;
let report_path = args.report.clone().unwrap_or_else(|| {
repo_root.join(format!(
"dist/run-{}/determinism.json",
commit_short(&commit)
))
});
let preserve_dist = args.preserve_dist.as_ref().map(|p| {
if p.is_absolute() {
p.clone()
} else {
repo_root.join(p)
}
});
let log = StageLogger::new("check", verbosity);
let _section = log.group("check-determinism");
emit_run_summary(
&log,
targets.as_deref(),
&stages,
args.runs,
preserve_dist.as_deref(),
args.crate_name.as_deref(),
);
if let Some(ref cfg) = repo_config {
crate::pipeline::emit_config_advisories(cfg, &log);
for w in cfg.crate_universe_collision_warnings() {
log.warn(&w);
}
}
let state = DeterminismState::seed_from_commit(sde)
.context("seeding determinism state from HEAD commit timestamp")?;
let mut allowlist = AllowList {
compile_time: state
.compile_time_allowlist
.iter()
.map(|(n, r)| AllowListEntry {
artifact: n.clone(),
reason: r.clone(),
})
.collect(),
runtime: Vec::new(),
};
allowlist.runtime.extend(
repo_config
.as_ref()
.map(signature_allowlist_entries_from_config)
.unwrap_or_default(),
);
let version_hint =
read_project_version(&repo_root).unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_string());
let child_snapshot =
resolve_child_snapshot(args.snapshot, args.no_snapshot, head_is_at_tag(&repo_root)?);
if repo_config
.as_ref()
.map(anodizer_core::config::all_builds_prebuilt)
.unwrap_or(false)
{
eprintln!(
"{}",
render_note(
"determinism harness skipped: no buildable targets (all builds use `builder: prebuilt`)"
)
);
return Ok(());
}
let docker_backend_hint = repo_config.as_ref().and_then(detect_docker_backend_hint);
let docker_declared_raw =
crate_declares_docker(repo_config.as_ref(), args.crate_name.as_deref());
let docker_explicitly_requested =
args.require_tools || explicit_stages.contains(&StageId::Docker);
let (docker_configs, docker_declared) = if stages.contains(&StageId::Docker) {
classify_docker_stage_state(
resolve_docker_configs(
repo_config.as_ref(),
args.crate_name.as_deref(),
child_snapshot,
&log,
),
docker_declared_raw,
docker_explicitly_requested,
&log,
)?
} else {
(Vec::new(), docker_declared_raw)
};
let mut config_tools: BTreeMap<StageId, Vec<String>> = BTreeMap::new();
if stages.contains(&StageId::Msi) {
let tools = resolve_msi_tools(repo_config.as_ref());
if !tools.is_empty() {
config_tools.insert(StageId::Msi, tools);
}
}
if stages.contains(&StageId::Upx) {
let tools = resolve_upx_tools(repo_config.as_ref());
if !tools.is_empty() {
config_tools.insert(StageId::Upx, tools);
}
}
let harness = Harness {
repo_root: repo_root.clone(),
commit: commit.clone(),
stages,
explicit_stages,
require_tools: args.require_tools,
runs: args.runs,
sde,
allowlist,
report_path: report_path.clone(),
inject_drift,
targets,
preserve_dist,
version_hint,
child_snapshot,
docker_backend_hint,
docker_configs,
docker_declared,
crate_name: args.crate_name.clone(),
verbosity,
config_tools,
disk_abs_floor_bytes: anodizer_core::disk::abs_floor_bytes_from_env(),
disk_safety_factor: anodizer_core::disk::safety_factor_from_env(),
};
let report = harness.run()?;
if let Some(parent) = report_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating report directory {}", parent.display()))?;
}
let json =
serde_json::to_string_pretty(&report).context("serializing determinism report to JSON")?;
std::fs::write(&report_path, json)
.with_context(|| format!("writing report to {}", report_path.display()))?;
if verbosity > Verbosity::Quiet {
eprintln!(
"{}",
render_note(&format!(
"wrote determinism report to {}",
report_path.display()
))
);
}
if report.drift_count > 0 {
eprintln!(
"{}",
render_error(&format!(
"drift detected: {} artifact(s) differed across {} runs",
report.drift_count, report.runs
))
);
for d in &report.drift {
let detail = match &d.differing_bytes_summary {
Some(summary) => format!("{}: {} | {:?}", d.artifact, summary, d.hashes),
None => format!("{}: {:?}", d.artifact, d.hashes),
};
log.failure(&detail);
}
std::process::exit(1);
}
log.success(&format!(
"{}/{} runs byte-identical",
report.runs, report.runs
));
Ok(())
}
fn parse_stages(s: Option<&str>) -> Result<Vec<StageId>, String> {
match s {
None => Ok(default_stages_for_host()),
Some(list) => {
let mut parsed: Vec<StageId> = Vec::new();
let mut unknown: Vec<String> = Vec::new();
for tok in list.split(',') {
let tok = tok.trim();
if tok.is_empty() {
continue;
}
if tok == "installers" {
parsed.extend(installer_stages());
} else if let Some(stage) = StageId::from_token(tok) {
parsed.push(stage);
} else {
unknown.push(tok.to_string());
}
}
if !unknown.is_empty() {
let mut known: Vec<&str> = StageId::iter().map(StageId::as_str).collect();
known.push("installers");
return Err(format!(
"--stages contained unknown stage(s): {}. Known stages: {}.",
unknown.join(", "),
known.join(", ")
));
}
let mut seen: std::collections::HashSet<StageId> = std::collections::HashSet::new();
let mut deduped: Vec<StageId> = Vec::with_capacity(parsed.len());
for stage in parsed {
if seen.insert(stage) {
deduped.push(stage);
}
}
Ok(if deduped.is_empty() {
default_stages_for_host()
} else {
deduped
})
}
}
}
fn default_stages_for_host() -> Vec<StageId> {
let mut stages = ALWAYS_ON_STAGES.to_vec();
let host_os = if cfg!(target_os = "linux") {
"linux"
} else if cfg!(target_os = "macos") {
"macos"
} else if cfg!(target_os = "windows") {
"windows"
} else {
""
};
for token in anodizer_core::env_preflight::os_native_producer_tokens(host_os) {
stages.push(
StageId::from_token(token).expect("core producer token must map to a StageId variant"),
);
}
stages
}
const ALWAYS_ON_STAGES: &[StageId] = &[
StageId::Build,
StageId::Source,
StageId::Upx,
StageId::Archive,
StageId::Sbom,
StageId::Sign,
StageId::Checksum,
];
fn host_default_for_config(config: Option<&anodizer_core::config::Config>) -> Vec<StageId> {
let full = default_stages_for_host();
let Some(config) = config else {
return full;
};
let configured = anodizer_core::env_preflight::configured_producer_stages(config);
full.into_iter()
.filter(|s| ALWAYS_ON_STAGES.contains(s) || configured.contains(s.as_str()))
.collect()
}
fn is_explicit_stage_selection(stages_arg: Option<&str>) -> bool {
matches!(stages_arg, Some(list) if list.split(',').any(|t| !t.trim().is_empty()))
}
fn resolve_stages(
stages_arg: Option<&str>,
config: Option<&anodizer_core::config::Config>,
) -> Result<Vec<StageId>, String> {
if is_explicit_stage_selection(stages_arg) {
parse_stages(stages_arg)
} else {
Ok(host_default_for_config(config))
}
}
fn parse_targets(s: Option<&str>) -> Result<Option<Vec<String>>, String> {
crate::commands::helpers::parse_csv_list(
s,
"--targets=x86_64-unknown-linux-gnu,aarch64-unknown-linux-gnu",
)
}
fn commit_short(commit: &str) -> String {
commit.get(..7).unwrap_or(commit).to_string()
}
fn emit_run_summary(
log: &StageLogger,
targets: Option<&[String]>,
stages: &[StageId],
runs: u32,
preserve_dist: Option<&std::path::Path>,
crate_name: Option<&str>,
) {
let targets_value = match targets {
Some(t) if !t.is_empty() => t.join(", "),
_ => "all (from config)".to_string(),
};
let stages_value = stages
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ");
let preserve_value = preserve_dist.map(|p| p.display().to_string());
let mut rows: Vec<(&str, &str)> = vec![("targets", targets_value.as_str())];
rows.push(("stages", stages_value.as_str()));
let runs_value = runs.to_string();
rows.push(("runs", runs_value.as_str()));
if let Some(ref v) = preserve_value {
rows.push(("preserve-dist", v.as_str()));
}
if let Some(name) = crate_name {
rows.push(("crate", name));
}
let key_width = rows.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
for (key, value) in rows {
log.kv(key, value, key_width);
}
}
fn resolve_child_snapshot(snapshot: bool, no_snapshot: bool, head_at_tag: bool) -> bool {
if snapshot {
true
} else if no_snapshot {
false
} else {
!head_at_tag
}
}
fn signature_suffix(template: &str) -> Option<String> {
let tail = match template.rfind("}}") {
Some(idx) => &template[idx + 2..],
None => template,
};
let tail = tail.trim();
if tail.len() < 2 || !tail.starts_with('.') {
return None;
}
Some(tail.to_string())
}
fn signature_allowlist_entries_from_config(
cfg: &anodizer_core::config::Config,
) -> Vec<AllowListEntry> {
use anodizer_core::config::SignConfig;
let mut suffixes: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
let mut collect = |entries: &[SignConfig], default_tmpl: &str| {
for s in entries {
if let Some(suffix) = signature_suffix(s.resolved_signature_template(default_tmpl)) {
suffixes.insert(suffix);
}
}
};
collect(&cfg.signs, SignConfig::DEFAULT_SIGNATURE_TEMPLATE);
collect(
&cfg.binary_signs,
SignConfig::DEFAULT_BINARY_SIGNATURE_TEMPLATE,
);
for w in cfg.workspaces.iter().flatten() {
collect(&w.signs, SignConfig::DEFAULT_SIGNATURE_TEMPLATE);
collect(
&w.binary_signs,
SignConfig::DEFAULT_BINARY_SIGNATURE_TEMPLATE,
);
}
suffixes
.into_iter()
.map(|suffix| AllowListEntry {
reason: format!(
"signature artifact ({suffix}): signature bytes vary by signer \
(cosign signs with a random ECDSA nonce); validate cryptographically \
via `cosign verify-blob` / `gpg --verify`, not byte-equality"
),
artifact: format!("*{suffix}"),
})
.collect()
}
fn detect_docker_backend_hint(cfg: &anodizer_core::config::Config) -> Option<String> {
let mut saw_buildx = false;
let mut iter: Vec<&Option<String>> = Vec::new();
if let Some(ref defaults) = cfg.defaults
&& let Some(ref v2) = defaults.dockers_v2
{
iter.push(&v2.use_backend);
}
for c in cfg.crate_universe() {
if let Some(ref v2s) = c.dockers_v2 {
for v in v2s {
iter.push(&v.use_backend);
}
}
}
for opt in iter {
match opt.as_deref() {
Some("podman") => return Some("podman".to_string()),
Some("buildx") | None => saw_buildx = true,
Some(_) => {}
}
}
if saw_buildx {
Some("buildx".to_string())
} else {
None
}
}
fn crate_universe<'a>(
cfg: &'a anodizer_core::config::Config,
crate_name: Option<&'a str>,
) -> impl Iterator<Item = &'a anodizer_core::config::CrateConfig> {
cfg.crate_universe()
.into_iter()
.filter(move |k| crate_name.is_none_or(|n| k.name == n))
}
fn crate_declares_docker(
repo_config: Option<&anodizer_core::config::Config>,
crate_name: Option<&str>,
) -> bool {
repo_config.is_some_and(|cfg| {
crate_universe(cfg, crate_name)
.any(|k| k.dockers_v2.as_ref().is_some_and(|v| !v.is_empty()))
})
}
fn resolve_docker_configs(
repo_config: Option<&anodizer_core::config::Config>,
crate_name: Option<&str>,
child_snapshot: bool,
log: &StageLogger,
) -> Result<Vec<ResolvedDockerConfig>> {
let Some(cfg) = repo_config else {
return Ok(Vec::new());
};
let opts = anodizer_core::context::ContextOptions {
snapshot: child_snapshot,
selected_crates: crate_name.map(|n| vec![n.to_string()]).unwrap_or_default(),
skip_stages: anodizer_core::determinism_runner::SIDE_EFFECT_STAGES
.iter()
.map(|s| (*s).to_string())
.collect(),
..Default::default()
};
let mut ctx = anodizer_core::context::Context::new(cfg.clone(), opts);
ctx.populate_time_vars();
ctx.populate_runtime_vars();
ctx.populate_metadata_var()?;
crate::commands::helpers::setup_env(&mut ctx, cfg, log)?;
crate::commands::helpers::resolve_git_context(&mut ctx, cfg, log)?;
if child_snapshot {
crate::commands::release::apply_snapshot_template_vars(&mut ctx, cfg, log)?;
}
let mut out: Vec<ResolvedDockerConfig> = Vec::new();
for krate in crate_universe(cfg, crate_name) {
let Some(entries) = krate.dockers_v2.as_ref() else {
continue;
};
for (idx, entry) in entries.iter().enumerate() {
if anodizer_stage_docker::is_docker_v2_skipped(&entry.skip, &ctx).with_context(
|| format!("dockers_v2[{idx}]: evaluate skip for crate {}", krate.name),
)? {
continue;
}
let dockerfile = ctx.render_template(&entry.dockerfile).with_context(|| {
format!(
"dockers_v2[{idx}]: render dockerfile path '{}' for crate {}",
entry.dockerfile, krate.name
)
})?;
if dockerfile.trim().is_empty() {
continue;
}
let build_args = anodizer_stage_docker::render_v2_kv_map(
&mut ctx,
entry.build_args.as_ref(),
"build_arg",
)?;
out.push(ResolvedDockerConfig {
dockerfile,
extra_files: entry.extra_files.clone().unwrap_or_default(),
build_args,
});
}
}
Ok(out)
}
fn classify_docker_stage_state(
resolved: Result<Vec<ResolvedDockerConfig>>,
declared: bool,
explicitly_requested: bool,
log: &StageLogger,
) -> Result<(Vec<ResolvedDockerConfig>, bool)> {
match resolved {
Ok(v) => Ok((v, declared)),
Err(e) if explicitly_requested => Err(e).context(
"resolving dockers_v2 for the determinism docker stage (--require-tools / \
explicit --stages=docker)",
),
Err(e) => {
log.warn(&format!(
"skipping docker stage — could not resolve dockers_v2 for this run: {e:#}"
));
Ok((Vec::new(), false))
}
}
}
fn resolve_msi_tools(repo_config: Option<&anodizer_core::config::Config>) -> Vec<String> {
let Some(cfg) = repo_config else {
return Vec::new();
};
let ctx = anodizer_core::context::Context::new(
cfg.clone(),
anodizer_core::context::ContextOptions::default(),
);
anodizer_stage_msi::required_msi_tools(&ctx)
}
fn resolve_upx_tools(repo_config: Option<&anodizer_core::config::Config>) -> Vec<String> {
let Some(cfg) = repo_config else {
return Vec::new();
};
let ctx = anodizer_core::context::Context::new(
cfg.clone(),
anodizer_core::context::ContextOptions::default(),
);
let mut tools = anodizer_stage_upx::required_upx_tools(&ctx);
for entry in &cfg.upx {
if entry.enabled.as_ref().is_some_and(|e| e.is_template()) && !tools.contains(&entry.binary)
{
tools.push(entry.binary.clone());
}
}
tools
}
fn read_project_version(repo_root: &std::path::Path) -> Option<String> {
let manifest = repo_root.join("Cargo.toml");
let text = std::fs::read_to_string(&manifest).ok()?;
let doc: toml::Value = toml::from_str(&text).ok()?;
doc.get("workspace")
.and_then(|w| w.get("package"))
.and_then(|p| p.get("version"))
.and_then(|v| v.as_str())
.map(str::to_string)
.or_else(|| {
doc.get("package")
.and_then(|p| p.get("version"))
.and_then(|v| v.as_str())
.map(str::to_string)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_msi_tools_threads_resolved_wix_tool_from_config() {
use anodizer_core::config::{Config, CrateConfig, MsiConfig};
let msi_cfg = MsiConfig {
wxs: Some("app.wxs".to_string()),
version: Some("v4".to_string()),
..Default::default()
};
let config = Config {
project_name: "myapp".to_string(),
crates: vec![CrateConfig {
name: "myapp".to_string(),
path: ".".to_string(),
tag_template: "v{{ .Version }}".to_string(),
msis: Some(vec![msi_cfg]),
..Default::default()
}],
..Default::default()
};
assert_eq!(resolve_msi_tools(Some(&config)), vec!["wix".to_string()]);
}
#[test]
fn resolve_msi_tools_none_config_is_empty() {
assert!(resolve_msi_tools(None).is_empty());
}
#[test]
fn resolve_upx_tools_threads_enabled_binary_from_config() {
use anodizer_core::config::{Config, StringOrBool, UpxConfig};
let config = Config {
project_name: "myapp".to_string(),
upx: vec![
UpxConfig {
enabled: Some(StringOrBool::Bool(true)),
binary: "upx".to_string(),
..Default::default()
},
UpxConfig {
enabled: Some(StringOrBool::Bool(false)),
binary: "other-upx".to_string(),
..Default::default()
},
],
..Default::default()
};
assert_eq!(resolve_upx_tools(Some(&config)), vec!["upx".to_string()]);
}
#[test]
fn resolve_upx_tools_none_config_is_empty() {
assert!(resolve_upx_tools(None).is_empty());
}
#[test]
fn resolve_upx_tools_force_requires_templated_enabled() {
use anodizer_core::config::{Config, StringOrBool, UpxConfig};
let config = Config {
project_name: "myapp".to_string(),
upx: vec![UpxConfig {
enabled: Some(StringOrBool::String(
"{{ if false }}true{{ else }}false{{ end }}".to_string(),
)),
binary: "upx".to_string(),
..Default::default()
}],
..Default::default()
};
assert_eq!(resolve_upx_tools(Some(&config)), vec!["upx".to_string()]);
}
#[test]
fn resolve_upx_tools_omits_literal_false_enabled() {
use anodizer_core::config::{Config, StringOrBool, UpxConfig};
let config = Config {
project_name: "myapp".to_string(),
upx: vec![UpxConfig {
enabled: Some(StringOrBool::Bool(false)),
binary: "upx".to_string(),
..Default::default()
}],
..Default::default()
};
assert!(resolve_upx_tools(Some(&config)).is_empty());
}
#[test]
fn parse_stages_default_returns_host_native_partition() {
let stages = parse_stages(None).expect("None is always Ok");
assert_eq!(stages, default_stages_for_host());
for base in [
StageId::Build,
StageId::Source,
StageId::Upx,
StageId::Archive,
StageId::Sbom,
StageId::Sign,
StageId::Checksum,
] {
assert!(stages.contains(&base), "base stage {base:?} missing");
}
}
#[test]
fn default_stages_for_host_includes_os_native_producers() {
let stages = default_stages_for_host();
assert!(
!stages.contains(&StageId::CargoPackage),
"cargo-package must never be in the host default"
);
#[cfg(target_os = "linux")]
for s in [
StageId::Nfpm,
StageId::Makeself,
StageId::Snapcraft,
StageId::Srpm,
StageId::Docker,
StageId::Appimage,
StageId::Flatpak,
] {
assert!(stages.contains(&s), "linux default missing {s:?}");
}
#[cfg(target_os = "macos")]
for s in [StageId::Appbundle, StageId::Dmg, StageId::Pkg] {
assert!(stages.contains(&s), "macos default missing {s:?}");
}
#[cfg(target_os = "windows")]
for s in [StageId::Msi, StageId::Nsis] {
assert!(stages.contains(&s), "windows default missing {s:?}");
}
}
#[test]
fn derived_stage_sets_are_subsets_of_stage_id_vocabulary() {
let vocab: std::collections::HashSet<StageId> = StageId::iter().collect();
for s in default_stages_for_host() {
assert!(
vocab.contains(&s),
"host-default stage {s:?} not in StageId vocabulary"
);
}
use anodizer_core::config::{Config, CrateConfig, NfpmConfig, SrpmConfig};
let config = Config {
crates: vec![CrateConfig {
name: "x".to_string(),
path: ".".to_string(),
nfpms: Some(vec![NfpmConfig::default()]),
..Default::default()
}],
srpms: Some(SrpmConfig::default()),
..Default::default()
};
let configured = anodizer_core::env_preflight::configured_producer_stages(&config);
assert!(configured.contains("nfpm") && configured.contains("srpm"));
for tok in &configured {
let s = StageId::from_token(tok)
.unwrap_or_else(|| panic!("producer token `{tok}` has no StageId variant"));
assert!(vocab.contains(&s));
}
let host: std::collections::HashSet<StageId> =
default_stages_for_host().into_iter().collect();
#[cfg(target_os = "linux")]
for s in [
StageId::Nfpm,
StageId::Makeself,
StageId::Snapcraft,
StageId::Srpm,
StageId::Docker,
StageId::Appimage,
StageId::Flatpak,
] {
assert!(host.contains(&s), "linux derived default missing {s:?}");
}
#[cfg(target_os = "macos")]
for s in [StageId::Appbundle, StageId::Dmg, StageId::Pkg] {
assert!(host.contains(&s), "macos derived default missing {s:?}");
}
#[cfg(target_os = "windows")]
for s in [StageId::Msi, StageId::Nsis] {
assert!(host.contains(&s), "windows derived default missing {s:?}");
}
assert!(!host.contains(&StageId::CargoPackage));
}
#[test]
fn parse_stages_accepts_appimage_and_flatpak() {
let stages = parse_stages(Some("appimage,flatpak")).expect("both are known stages");
assert_eq!(stages, vec![StageId::Appimage, StageId::Flatpak]);
}
#[test]
fn host_default_excludes_unconfigured_producers() {
use anodizer_core::config::{Config, CrateConfig};
let config = Config {
project_name: "minimal".to_string(),
crates: vec![CrateConfig {
name: "minimal".to_string(),
path: ".".to_string(),
tag_template: "v{{ .Version }}".to_string(),
..Default::default()
}],
..Default::default()
};
let stages = host_default_for_config(Some(&config));
for base in ALWAYS_ON_STAGES {
assert!(stages.contains(base), "base stage {base:?} must remain");
}
for gated in [
StageId::Nfpm,
StageId::Makeself,
StageId::Snapcraft,
StageId::Srpm,
StageId::Docker,
StageId::Appimage,
StageId::Flatpak,
StageId::Appbundle,
StageId::Dmg,
StageId::Pkg,
StageId::Msi,
StageId::Nsis,
] {
assert!(
!stages.contains(&gated),
"unconfigured producer {gated:?} must be pruned from the default"
);
}
}
#[cfg(target_os = "linux")]
#[test]
fn host_default_includes_configured_linux_producers() {
use anodizer_core::config::{
AppImageConfig, Config, CrateConfig, DockerV2Config, FlatpakConfig, MakeselfConfig,
NfpmConfig, SnapcraftConfig, SrpmConfig,
};
let config = Config {
project_name: "full".to_string(),
crates: vec![CrateConfig {
name: "full".to_string(),
path: ".".to_string(),
tag_template: "v{{ .Version }}".to_string(),
nfpms: Some(vec![NfpmConfig::default()]),
snapcrafts: Some(vec![SnapcraftConfig::default()]),
flatpaks: Some(vec![FlatpakConfig::default()]),
dockers_v2: Some(vec![DockerV2Config::default()]),
..Default::default()
}],
appimages: vec![AppImageConfig::default()],
makeselfs: vec![MakeselfConfig::default()],
srpms: Some(SrpmConfig::default()),
..Default::default()
};
let stages = host_default_for_config(Some(&config));
for producer in [
StageId::Nfpm,
StageId::Makeself,
StageId::Snapcraft,
StageId::Srpm,
StageId::Docker,
StageId::Appimage,
StageId::Flatpak,
] {
assert!(
stages.contains(&producer),
"configured producer {producer:?} must stay in the default"
);
}
}
#[test]
fn host_default_none_config_is_full_partition() {
assert_eq!(host_default_for_config(None), default_stages_for_host());
}
#[test]
fn resolve_docker_configs_propagates_render_error() {
use anodizer_core::config::{Config, CrateConfig, DockerV2Config};
let config = Config {
project_name: "full".to_string(),
crates: vec![CrateConfig {
name: "full".to_string(),
path: ".".to_string(),
tag_template: "v{{ .Version }}".to_string(),
dockers_v2: Some(vec![DockerV2Config {
dockerfile: "{{ Version | this_filter_does_not_exist }}".to_string(),
..Default::default()
}]),
..Default::default()
}],
..Default::default()
};
let log = StageLogger::new("test", Verbosity::Quiet);
let err = resolve_docker_configs(Some(&config), Some("full"), true, &log)
.expect_err("a malformed dockerfile template must propagate, not resolve to empty");
let msg = format!("{err:#}");
assert!(
msg.contains("dockerfile"),
"the propagated error must be the dockerfile render failure (not a swallowed empty \
result nor an unrelated setup error): {msg}"
);
}
#[test]
fn resolve_docker_configs_truthy_skip_resolves_empty_without_error() {
use anodizer_core::config::{Config, CrateConfig, DockerV2Config, StringOrBool};
let config = Config {
project_name: "full".to_string(),
crates: vec![CrateConfig {
name: "full".to_string(),
path: ".".to_string(),
tag_template: "v{{ .Version }}".to_string(),
dockers_v2: Some(vec![DockerV2Config {
dockerfile: "Dockerfile.release".to_string(),
skip: Some(StringOrBool::Bool(true)),
..Default::default()
}]),
..Default::default()
}],
..Default::default()
};
let log = StageLogger::new("test", Verbosity::Quiet);
let resolved = resolve_docker_configs(Some(&config), Some("full"), true, &log)
.expect("a truthy skip must resolve cleanly (Ok), mirroring production");
assert!(
resolved.is_empty(),
"a skipped entry must produce no ResolvedDockerConfig: {resolved:?}"
);
}
#[test]
fn setup_env_release_mode_skips_token_gate_with_determinism_skip_stages() {
use anodizer_core::config::{Config, CrateConfig, ReleaseConfig};
use anodizer_core::context::{Context, ContextOptions};
let skip_stages: Vec<String> = anodizer_core::determinism_runner::SIDE_EFFECT_STAGES
.iter()
.map(|s| (*s).to_string())
.collect();
let config = Config {
project_name: "full".to_string(),
crates: vec![CrateConfig {
name: "full".to_string(),
path: ".".to_string(),
release: Some(ReleaseConfig::default()),
..Default::default()
}],
..Default::default()
};
let opts = ContextOptions {
snapshot: false,
skip_stages,
..Default::default()
};
let mut ctx = Context::new(config.clone(), opts);
ctx.set_env_source(anodizer_core::env_source::MapEnvSource::new());
assert!(
ctx.should_skip("release"),
"resolve_docker_configs's skip_stages must mirror the determinism child \
build's SIDE_EFFECT_STAGES"
);
let log = StageLogger::new("test", Verbosity::Quiet);
crate::commands::helpers::setup_env(&mut ctx, &config, &log).expect(
"a release-mode (tagged-HEAD) probe with skip_stages mirroring the \
determinism child build must not demand a GitHub token",
);
}
#[test]
fn classify_docker_stage_state_host_default_error_forces_not_declared() {
let log = StageLogger::new("test", Verbosity::Quiet);
let (configs, declared) = classify_docker_stage_state(
Err(anyhow::anyhow!("boom: could not resolve")),
true, false, &log,
)
.expect("a host-default resolve error must warn-and-skip, not hard-fail");
assert!(configs.is_empty(), "an errored resolve carries no configs");
assert!(
!declared,
"an errored host-default resolve must be reflected as NOT declared so the harness \
quiet-skips instead of emitting the misleading legit-skip warn"
);
}
#[test]
fn classify_docker_stage_state_explicit_error_hard_fails() {
let log = StageLogger::new("test", Verbosity::Quiet);
let res = classify_docker_stage_state(
Err(anyhow::anyhow!("boom: could not resolve")),
true,
true, &log,
);
assert!(
res.is_err(),
"an explicit docker request whose resolve errored must hard-fail, not skip"
);
}
#[test]
fn classify_docker_stage_state_ok_preserves_declared_and_configs() {
let log = StageLogger::new("test", Verbosity::Quiet);
let resolved = vec![ResolvedDockerConfig {
dockerfile: "FROM scratch\n".to_string(),
extra_files: Vec::new(),
build_args: Vec::new(),
}];
let (configs, declared) = classify_docker_stage_state(Ok(resolved), true, true, &log)
.expect("Ok must pass through");
assert_eq!(configs.len(), 1, "resolved configs must be carried through");
assert!(declared, "a successful resolve must not alter `declared`");
}
#[test]
fn resolve_stages_explicit_ignores_config() {
use anodizer_core::config::{Config, CrateConfig};
let bare = Config {
crates: vec![CrateConfig {
name: "x".to_string(),
path: ".".to_string(),
..Default::default()
}],
..Default::default()
};
let stages = resolve_stages(Some("nfpm"), Some(&bare)).expect("nfpm is a known stage");
assert_eq!(stages, vec![StageId::Nfpm]);
}
#[test]
fn is_explicit_stage_selection_matches_nonblank_token_only() {
assert!(is_explicit_stage_selection(Some("msi")));
assert!(is_explicit_stage_selection(Some(" archive , checksum ")));
assert!(!is_explicit_stage_selection(None));
assert!(!is_explicit_stage_selection(Some("")));
assert!(!is_explicit_stage_selection(Some(" , , ")));
}
#[test]
fn parse_stages_subset_filters_to_named_set() {
let stages = parse_stages(Some("archive,checksum")).expect("all known stages");
assert_eq!(
stages.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
vec!["archive", "checksum"]
);
}
#[test]
fn parse_stages_accepts_full_byte_stable_set() {
let stages = parse_stages(Some(
"build,source,upx,archive,nfpm,makeself,snapcraft,sbom,sign,checksum",
))
.expect("all stages in the action's Linux default must parse");
assert_eq!(
stages.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
vec![
"build",
"source",
"upx",
"archive",
"nfpm",
"makeself",
"snapcraft",
"sbom",
"sign",
"checksum"
]
);
}
#[test]
fn parse_stages_errors_on_unknown_token() {
let err = parse_stages(Some(" archive , bogus, checksum "))
.expect_err("unknown token must error");
assert!(
err.contains("bogus") && err.contains("Known stages"),
"error must name the bad token and the legal vocabulary: {err}"
);
let err = parse_stages(Some("archve,nope")).expect_err("multiple unknowns must error");
assert!(
err.contains("archve") && err.contains("nope"),
"all unknown tokens must be named: {err}"
);
}
#[test]
fn parse_stages_tolerates_trailing_comma_and_whitespace() {
let stages = parse_stages(Some("archive,checksum,")).expect("trailing comma tolerated");
assert_eq!(
stages.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
vec!["archive", "checksum"]
);
let stages = parse_stages(Some(" archive , , checksum ")).expect("empty middle tolerated");
assert_eq!(
stages.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
vec!["archive", "checksum"]
);
}
#[test]
fn parse_stages_installers_umbrella_expands_to_full_set() {
let stages = parse_stages(Some("installers")).expect("umbrella token must parse");
assert_eq!(
stages.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
vec!["nfpm", "makeself", "srpm", "msi", "nsis", "dmg", "pkg"]
);
}
#[test]
fn parse_stages_installers_dedupes_against_individual_members() {
let stages =
parse_stages(Some("installers,msi")).expect("umbrella + individual must parse");
let names: Vec<&str> = stages.iter().map(|s| s.as_str()).collect();
assert_eq!(names.iter().filter(|n| **n == "msi").count(), 1);
}
#[test]
fn parse_stages_accepts_each_individual_installer_token() {
for token in ["msi", "nsis", "dmg", "pkg", "srpm"] {
let stages = parse_stages(Some(token))
.unwrap_or_else(|e| panic!("token `{token}` must parse: {e}"));
assert_eq!(
stages.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
vec![token]
);
}
}
#[test]
fn parse_stages_accepts_appbundle_token() {
let stages = parse_stages(Some("appbundle,dmg")).expect("appbundle token must parse");
assert_eq!(
stages.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
vec!["appbundle", "dmg"]
);
}
#[test]
fn parse_stages_empty_string_falls_back_to_default() {
let expected = default_stages_for_host();
let stages = parse_stages(Some("")).expect("empty list returns default");
assert_eq!(stages, expected);
let stages = parse_stages(Some(" , , ")).expect("whitespace-only returns default");
assert_eq!(stages, expected);
}
#[test]
fn parse_targets_default_is_none() {
assert_eq!(parse_targets(None).unwrap(), None);
}
#[test]
fn parse_targets_subset_filters_to_named_list() {
let got = parse_targets(Some("x86_64-unknown-linux-gnu,aarch64-unknown-linux-gnu"))
.expect("ascii triples accepted");
assert_eq!(
got,
Some(vec![
"x86_64-unknown-linux-gnu".to_string(),
"aarch64-unknown-linux-gnu".to_string(),
])
);
}
#[test]
fn parse_targets_tolerates_trailing_comma_and_whitespace() {
let got = parse_targets(Some(" x86_64-apple-darwin , aarch64-apple-darwin , "))
.expect("trailing comma + spaces tolerated");
assert_eq!(
got,
Some(vec![
"x86_64-apple-darwin".to_string(),
"aarch64-apple-darwin".to_string(),
])
);
}
#[test]
fn parse_targets_errors_on_all_empty_csv() {
let err = parse_targets(Some("")).expect_err("empty CSV must error");
assert!(
err.contains("at least one entry"),
"error must explain the requirement: {err}"
);
let err = parse_targets(Some(" , , ")).expect_err("whitespace-only CSV must error");
assert!(
err.contains("at least one entry"),
"error must explain the requirement: {err}"
);
}
#[test]
fn commit_short_truncates_to_seven_chars() {
assert_eq!(commit_short("abcdef1234567890"), "abcdef1");
}
#[test]
fn commit_short_keeps_short_commit_as_is() {
assert_eq!(commit_short("abc"), "abc");
}
#[test]
fn dispatcher_args_are_consumed() {
let _args = CheckDeterminismArgs {
runs: 2,
stages: None,
targets: None,
report: None,
snapshot: false,
no_snapshot: false,
inject_drift: None,
preserve_dist: None,
crate_name: None,
require_tools: false,
};
}
#[test]
fn resolve_child_snapshot_auto_off_when_head_at_tag() {
assert!(!resolve_child_snapshot(false, false, true));
}
#[test]
fn resolve_child_snapshot_auto_on_when_head_not_at_tag() {
assert!(resolve_child_snapshot(false, false, false));
}
#[test]
fn resolve_child_snapshot_explicit_snapshot_beats_auto() {
assert!(resolve_child_snapshot(true, false, true));
assert!(resolve_child_snapshot(true, false, false));
}
#[test]
fn resolve_child_snapshot_explicit_no_snapshot_beats_auto() {
assert!(!resolve_child_snapshot(false, true, false));
assert!(!resolve_child_snapshot(false, true, true));
}
#[test]
fn read_project_version_returns_none_when_cargo_toml_missing() {
let tmp = tempfile::tempdir().unwrap();
assert_eq!(read_project_version(tmp.path()), None);
}
#[test]
fn read_project_version_reads_workspace_package_version() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("Cargo.toml"),
r#"[workspace]
members = ["crates/*"]
[workspace.package]
version = "1.2.3-test"
edition = "2021"
"#,
)
.unwrap();
assert_eq!(
read_project_version(tmp.path()),
Some("1.2.3-test".to_string())
);
}
#[test]
fn read_project_version_reads_package_version() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("Cargo.toml"),
r#"[package]
name = "demo"
version = "0.4.2"
edition = "2021"
"#,
)
.unwrap();
assert_eq!(read_project_version(tmp.path()), Some("0.4.2".to_string()));
}
#[test]
fn read_project_version_prefers_workspace_when_both_present() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("Cargo.toml"),
r#"[workspace.package]
version = "9.9.9"
[package]
name = "root-crate"
version = "0.0.1"
"#,
)
.unwrap();
assert_eq!(read_project_version(tmp.path()), Some("9.9.9".to_string()));
}
#[test]
fn read_project_version_returns_none_on_malformed_toml() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("Cargo.toml"), "not valid \x00 toml ===").unwrap();
assert_eq!(read_project_version(tmp.path()), None);
}
#[test]
fn signature_suffix_extracts_literal_tail_after_last_expansion() {
assert_eq!(
signature_suffix("{{ .Artifact }}.cosign.bundle").as_deref(),
Some(".cosign.bundle")
);
assert_eq!(
signature_suffix("{{ .Artifact }}.sig").as_deref(),
Some(".sig")
);
assert_eq!(
signature_suffix("{{ .Artifact }}.asc").as_deref(),
Some(".asc")
);
}
#[test]
fn signature_suffix_rejects_unanchorable_templates() {
assert_eq!(signature_suffix("{{ .Artifact }}"), None);
assert_eq!(signature_suffix("{{ .Artifact }} "), None);
assert_eq!(signature_suffix("{{ .Artifact }}sig"), None);
}
#[test]
fn signature_allowlist_derives_custom_cosign_bundle_suffix() {
use anodizer_core::config::{Config, SignConfig};
let cfg = Config {
signs: vec![
SignConfig {
signature: Some("{{ .Artifact }}.cosign.bundle".into()),
..Default::default()
},
SignConfig::default(), ],
..Default::default()
};
let entries = signature_allowlist_entries_from_config(&cfg);
let patterns: Vec<&str> = entries.iter().map(|e| e.artifact.as_str()).collect();
assert!(
patterns.contains(&"*.cosign.bundle"),
"custom signature suffix must be allow-listed, got {patterns:?}"
);
assert!(patterns.contains(&"*.sig"), "got {patterns:?}");
assert!(entries.iter().all(|e| e.artifact != "*"));
}
#[test]
fn cfgd_v040_drift_set_is_fully_allowlisted() {
use anodizer_core::DeterminismState;
use anodizer_core::config::{Config, SignConfig};
let cfg = Config {
signs: vec![
SignConfig {
signature: Some("{{ .Artifact }}.cosign.bundle".into()),
..Default::default()
},
SignConfig::default(),
],
binary_signs: vec![SignConfig::default()],
..Default::default()
};
let mut state = DeterminismState::seed_from_commit(0).expect("non-negative");
for entry in signature_allowlist_entries_from_config(&cfg) {
state.append_runtime(entry.artifact, entry.reason);
}
let drifted = [
"cfgd-0.4.0-linux-amd64-installer.run.sha256.cosign.bundle",
"cfgd-0.4.0-linux-amd64.tar.gz.cdx.json",
"cfgd-0.4.0-linux-amd64.tar.gz.cdx.json.sha256",
"cfgd-0.4.0-linux-amd64.tar.gz.cdx.json.sha256.cosign.bundle",
"cfgd-0.4.0-linux-amd64.tar.gz.sha256.cosign.bundle",
"cfgd-0.4.0-linux-arm64-installer.run.sha256.cosign.bundle",
"cfgd-0.4.0-linux-arm64.tar.gz.cdx.json",
"cfgd-0.4.0-linux-arm64.tar.gz.cdx.json.sha256",
"cfgd-0.4.0-linux-arm64.tar.gz.cdx.json.sha256.cosign.bundle",
"cfgd-0.4.0-linux-arm64.tar.gz.sha256.cosign.bundle",
"cfgd-0.4.0-source.tar.gz.sha256.cosign.bundle",
"cfgd_0.4.0_linux_amd64.apk.sha256.cosign.bundle",
"cfgd_0.4.0_linux_amd64.deb.sha256.cosign.bundle",
"cfgd_0.4.0_linux_amd64.rpm.sha256.cosign.bundle",
"cfgd_0.4.0_linux_arm64.apk.sha256.cosign.bundle",
"cfgd_0.4.0_linux_arm64.deb.sha256.cosign.bundle",
"cfgd_0.4.0_linux_arm64.rpm.sha256.cosign.bundle",
"install.sh.sha256.cosign.bundle",
"cfgd-0.4.0-darwin-all.tar.gz.cdx.json",
"cfgd-0.4.0-darwin-all.tar.gz.cdx.json.sha256",
"cfgd-0.4.0-darwin-all.tar.gz.cdx.json.sha256.cosign.bundle",
"cfgd-0.4.0-darwin-all.tar.gz.sha256.cosign.bundle",
"cfgd-0.4.0-darwin-amd64.tar.gz.cdx.json",
"cfgd-0.4.0-darwin-arm64.tar.gz.cdx.json.sha256.cosign.bundle",
"cfgd_0.4.0_checksums.txt.cosign.bundle",
];
for name in drifted {
assert!(
state.resolve_reason(name).is_some(),
"{name} drifted v0.4.0 and must now be allow-listed"
);
}
assert!(
state
.resolve_reason("cfgd-0.4.0-linux-amd64.tar.gz")
.is_none(),
"archive bytes must still be drift-checked"
);
assert!(
state.resolve_reason("cfgd").is_none(),
"raw binary must still be drift-checked"
);
}
#[test]
fn signature_allowlist_collects_per_workspace_signs() {
use anodizer_core::config::{Config, SignConfig, WorkspaceConfig};
let cfg = Config {
workspaces: Some(vec![WorkspaceConfig {
name: "member".into(),
binary_signs: vec![SignConfig {
signature: Some("{{ .Artifact }}.bundle".into()),
..Default::default()
}],
..Default::default()
}]),
..Default::default()
};
let patterns: Vec<String> = signature_allowlist_entries_from_config(&cfg)
.into_iter()
.map(|e| e.artifact)
.collect();
assert!(
patterns.contains(&"*.bundle".to_string()),
"per-workspace signature suffix must be collected, got {patterns:?}"
);
}
}