use crate::determinism_harness::{Harness, 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};
pub fn run(args: CheckDeterminismArgs) -> Result<()> {
let repo_root = std::env::current_dir().context("resolving repo root")?;
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
};
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 stages = parse_stages(args.stages.as_deref()).map_err(|e| anyhow::anyhow!(e))?;
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 log = StageLogger::new("check", Verbosity::Normal);
log.step("Checking", "determinism");
emit_run_summary(&log, targets.as_deref(), &stages, args.runs);
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(derive_signature_allowlist_entries(&repo_root));
let preserve_dist = args.preserve_dist.as_ref().map(|p| {
if p.is_absolute() {
p.clone()
} else {
repo_root.join(p)
}
});
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 all_builds_prebuilt_in_repo(&repo_root) {
eprintln!(
"{}",
render_note(
"determinism harness skipped: no buildable targets (all builds use `builder: prebuilt`)"
)
);
return Ok(());
}
let docker_backend_hint = detect_docker_backend_hint(&repo_root);
let harness = Harness {
repo_root: repo_root.clone(),
commit: commit.clone(),
stages,
runs: args.runs,
sde,
allowlist,
report_path: report_path.clone(),
inject_drift,
targets,
preserve_dist,
version_hint,
child_snapshot,
docker_backend_hint,
crate_name: args.crate_name.clone(),
};
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()))?;
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);
}
Ok(())
}
fn parse_stages(s: Option<&str>) -> Result<Vec<StageId>, String> {
let default = || {
vec![
StageId::Build,
StageId::Archive,
StageId::Sbom,
StageId::Sign,
StageId::Checksum,
]
};
match s {
None => Ok(default()),
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;
}
match tok {
"build" => parsed.push(StageId::Build),
"source" => parsed.push(StageId::Source),
"upx" => parsed.push(StageId::Upx),
"archive" => parsed.push(StageId::Archive),
"nfpm" => parsed.push(StageId::Nfpm),
"makeself" => parsed.push(StageId::Makeself),
"snapcraft" => parsed.push(StageId::Snapcraft),
"sbom" => parsed.push(StageId::Sbom),
"sign" => parsed.push(StageId::Sign),
"checksum" => parsed.push(StageId::Checksum),
"cargo-package" => parsed.push(StageId::CargoPackage),
"docker" => parsed.push(StageId::Docker),
"msi" => parsed.push(StageId::Msi),
"nsis" => parsed.push(StageId::Nsis),
"dmg" => parsed.push(StageId::Dmg),
"pkg" => parsed.push(StageId::Pkg),
"srpm" => parsed.push(StageId::Srpm),
"installers" => parsed.extend(installer_stages()),
other => unknown.push(other.to_string()),
}
}
if !unknown.is_empty() {
return Err(format!(
"--stages contained unknown stage(s): {}. \
Known stages: build, source, upx, archive, nfpm, makeself, snapcraft, sbom, sign, checksum, cargo-package, docker, msi, nsis, dmg, pkg, srpm, installers.",
unknown.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()
} else {
deduped
})
}
}
}
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) {
const KEY_WIDTH: usize = "targets".len();
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(", ");
log.kv("targets", &targets_value, KEY_WIDTH);
log.kv("stages", &stages_value, KEY_WIDTH);
log.kv("runs", &runs.to_string(), 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 derive_signature_allowlist_entries(repo_root: &std::path::Path) -> Vec<AllowListEntry> {
match crate::pipeline::load_repo_config(repo_root).ok() {
Some(cfg) => signature_allowlist_entries_from_config(&cfg),
None => Vec::new(),
}
}
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 all_builds_prebuilt_in_repo(repo_root: &std::path::Path) -> bool {
crate::pipeline::load_repo_config(repo_root)
.ok()
.as_ref()
.map(anodizer_core::config::all_builds_prebuilt)
.unwrap_or(false)
}
fn detect_docker_backend_hint(repo_root: &std::path::Path) -> Option<String> {
let cfg = crate::pipeline::load_repo_config(repo_root).ok()?;
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.crates {
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 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 parse_stages_default_returns_full_build_side_set() {
let stages = parse_stages(None).expect("None is always Ok");
assert_eq!(
stages.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
vec!["build", "archive", "sbom", "sign", "checksum"]
);
}
#[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_empty_string_falls_back_to_default() {
let stages = parse_stages(Some("")).expect("empty list returns default");
assert_eq!(stages.len(), 5);
let stages = parse_stages(Some(" , , ")).expect("whitespace-only returns default");
assert_eq!(stages.len(), 5);
}
#[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,
};
}
#[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",
"artifacts.json",
"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:?}"
);
}
}