use anodizer_core::DeterminismReport;
use std::fs;
use std::process::Command;
use tempfile::TempDir;
mod common;
use common::{bootstrap_minimal_cargo_repo, tool_on_path};
#[test]
fn docker_stage_token_parses_and_runs_to_completion() {
if !tool_on_path("cargo") || !tool_on_path("git") {
eprintln!(
"SKIP docker_stage_token_parses_and_runs_to_completion: \
cargo or git missing from PATH"
);
return;
}
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
bootstrap_minimal_cargo_repo(repo, "anodize-docker-parse-fixture");
let report_path = repo.join("det.json");
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"check",
"determinism",
"--runs",
"2",
"--stages",
"docker",
"--report",
])
.arg(&report_path)
.current_dir(repo)
.output()
.expect("invoking anodize check determinism --stages=docker");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
report_path.exists(),
"report file missing at {}; stdout={} stderr={}",
report_path.display(),
stdout,
stderr
);
let json = fs::read_to_string(&report_path).unwrap();
let report: DeterminismReport =
serde_json::from_str(&json).unwrap_or_else(|e| panic!("parsing report JSON: {e}\n{json}"));
assert_eq!(report.schema_version, 1);
assert_eq!(report.runs, 2, "harness ran exactly --runs=2 times");
assert!(
report.stages_under_test.iter().any(|s| s == "docker"),
"stages_under_test must include `docker`: {:?}",
report.stages_under_test
);
let docker_rows: Vec<_> = report
.artifacts
.iter()
.filter(|a| a.stage == "docker")
.collect();
assert!(
docker_rows.is_empty(),
"expected no docker artifact rows when no Dockerfile is present; got {:?}",
docker_rows.iter().map(|r| &r.name).collect::<Vec<_>>()
);
assert!(
output.status.success(),
"harness exited non-zero on docker-skip path; stderr={}\nreport={}",
stderr,
json
);
assert_eq!(
report.drift_count, 0,
"drift_count must be zero on the no-Dockerfile no-op path: {:?}",
report.drift
);
}
#[test]
fn docker_oci_tar_is_byte_stable_on_minimal_dockerfile() {
if !tool_on_path("cargo") || !tool_on_path("git") {
eprintln!(
"SKIP docker_oci_tar_is_byte_stable_on_minimal_dockerfile: \
cargo or git missing from PATH"
);
return;
}
let buildx_ok = Command::new("docker")
.args(["buildx", "version"])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !buildx_ok {
eprintln!(
"SKIP docker_oci_tar_is_byte_stable_on_minimal_dockerfile: \
docker buildx not reachable"
);
return;
}
let probe_dir = TempDir::new().unwrap();
fs::write(probe_dir.path().join("Dockerfile"), "FROM scratch\n").unwrap();
let probe_tar = probe_dir.path().join("probe.tar");
let probe_home = probe_dir.path().join("empty-home");
fs::create_dir_all(&probe_home).unwrap();
let probe = Command::new("docker")
.args(["buildx", "build"])
.arg(format!(
"--output=type=oci,dest={}",
probe_tar.to_string_lossy()
))
.arg("--tag")
.arg("anodize/det:probe")
.arg(probe_dir.path())
.env_clear()
.env("HOME", &probe_home)
.env(
"PATH",
std::env::var("PATH").unwrap_or_else(|_| "/usr/bin:/bin".into()),
)
.output()
.expect("invoking docker buildx build for OCI exporter probe");
if !probe.status.success() {
eprintln!(
"SKIP docker_oci_tar_is_byte_stable_on_minimal_dockerfile: \
docker buildx OCI exporter not available in harness-equivalent env \
on this host (the `docker` driver requires `~/.docker/buildx/current` \
to select a `docker-container` builder; redirected HOME loses that \
selection so the harness cannot drive the exporter); probe stderr: {}",
String::from_utf8_lossy(&probe.stderr)
);
return;
}
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
bootstrap_minimal_cargo_repo(repo, "anodize-docker-fixture");
fs::write(
repo.join("Dockerfile"),
"FROM scratch\nLABEL anodize.fixture=det-harness\n",
)
.unwrap();
Command::new("git")
.args(["add", "Dockerfile"])
.current_dir(repo)
.status()
.expect("staging Dockerfile");
Command::new("git")
.args(["commit", "-q", "-m", "add Dockerfile"])
.current_dir(repo)
.status()
.expect("committing Dockerfile");
let report_path = repo.join("det.json");
let output = Command::new(env!("CARGO_BIN_EXE_anodizer"))
.args([
"check",
"determinism",
"--runs",
"2",
"--stages",
"docker",
"--report",
])
.arg(&report_path)
.current_dir(repo)
.output()
.expect("invoking anodize check determinism --stages=docker");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
report_path.exists(),
"report file missing at {}; stdout={} stderr={}",
report_path.display(),
stdout,
stderr
);
let json = fs::read_to_string(&report_path).unwrap();
let report: DeterminismReport =
serde_json::from_str(&json).unwrap_or_else(|e| panic!("parsing report JSON: {e}\n{json}"));
assert_eq!(report.schema_version, 1);
assert_eq!(report.runs, 2);
assert!(
report.stages_under_test.iter().any(|s| s == "docker"),
"stages_under_test must include `docker`: {:?}",
report.stages_under_test
);
let docker_rows: Vec<_> = report
.artifacts
.iter()
.filter(|a| a.stage == "docker")
.collect();
assert!(
!docker_rows.is_empty(),
"expected at least one docker artifact row; report.artifacts={:?}",
report
.artifacts
.iter()
.map(|a| (&a.name, &a.stage))
.collect::<Vec<_>>()
);
let has_oci_tar = docker_rows
.iter()
.any(|r| r.name.ends_with("image.oci.tar"));
let has_digest = docker_rows.iter().any(|r| r.name.ends_with("image.digest"));
assert!(
has_oci_tar,
"missing OCI tarball artifact row; got {:?}",
docker_rows.iter().map(|r| &r.name).collect::<Vec<_>>()
);
assert!(
has_digest,
"missing BuildKit image-digest companion row; got {:?}",
docker_rows.iter().map(|r| &r.name).collect::<Vec<_>>()
);
assert!(
output.status.success(),
"harness exited non-zero (drift detected); stderr={}\nreport={}",
stderr,
json
);
assert_eq!(
report.drift_count, 0,
"drift detected in docker output; drift rows: {:?}",
report.drift
);
for row in &docker_rows {
assert!(
row.deterministic,
"docker row `{}` must be deterministic; hashes={:?}",
row.name, row.hashes
);
assert!(
row.hash.is_some(),
"deterministic docker row `{}` must carry a single hash, not per-run array",
row.name
);
}
}