use anyhow::{Context as _, Result};
use std::path::{Path, PathBuf};
use anodizer_core::config::Config;
use anodizer_core::context::Context;
use anodizer_core::git::short_commit_str;
use anodizer_core::log::StageLogger;
use super::helpers;
use crate::pipeline;
const SIGN_ENV_VARS: &[&str] = &["COSIGN_KEY", "GPG_PRIVATE_KEY"];
const GITHUB_TOKEN_ENV_VARS: &[&str] = &["GITHUB_TOKEN", "ANODIZER_GITHUB_TOKEN"];
pub(super) struct RunOpts {
pub dry_run: bool,
pub no_preflight: bool,
}
pub(super) fn run(
ctx: &mut Context,
config: &Config,
log: &StageLogger,
opts: RunOpts,
) -> Result<()> {
log.status("running in publish-only mode (load preserved dist + sign + publish)...");
let dist = config.dist.clone();
if opts.dry_run {
log.verbose("(dry-run) skipping production-credential preflight");
} else if opts.no_preflight {
log.warn(
"credential preflight skipped via --no-preflight; \
missing credentials will fail mid-pipeline (no idempotent recovery)",
);
} else {
preflight_credentials(|k| std::env::var(k).ok())?;
}
if !ctx.config.binary_signs.is_empty() {
let n = ctx.config.binary_signs.len();
log.warn(&format!(
"publish-only: suppressing {n} binary_signs entrie(s); raw binaries are not \
preserved into dist/ by the determinism harness, so binary-level signatures \
cannot be (re-)produced in this mode. Configure archive-level signs: or sign \
on the consumer side."
));
ctx.config.binary_signs.clear();
}
check_no_unsuffixed_suffixed_collision(&dist, "context")?;
check_no_unsuffixed_suffixed_collision(&dist, "artifacts")?;
let preserved_contexts = discover_preserved_contexts(&dist)?;
let preserved = merge_preserved_contexts(&preserved_contexts)?;
let shard_count = preserved_contexts.len();
log.status(&format!(
"publish-only: loaded {} context manifest(s) (version={}, commit={}, targets=[{}], {} artifact(s))",
shard_count,
preserved.version,
short_commit_str(&preserved.commit),
preserved.targets.join(", "),
preserved.artifacts.len(),
));
hash_verify_preserved_dist(&preserved, &dist)?;
let ctx_commit = ctx
.template_vars()
.get("FullCommit")
.cloned()
.unwrap_or_default();
if ctx_commit.is_empty() {
anyhow::bail!(
"publish-only: current release context has no resolved commit. \
Run from a tagged commit (`git checkout {}`) before --publish-only.",
short_commit_str(&preserved.commit),
);
}
if ctx_commit != preserved.commit {
anyhow::bail!(
"publish-only: context manifest was preserved at commit {} but the current \
release context resolved to commit {}. Re-signing the preserved bytes \
under the current commit's tag would ship signatures that don't match \
the determinism-verified state. `git checkout {}` then retry.",
short_commit_str(&preserved.commit),
short_commit_str(&ctx_commit),
short_commit_str(&preserved.commit),
);
}
let artifact_manifests = discover_artifacts_manifests(&dist)?;
for manifest_path in &artifact_manifests {
helpers::load_artifacts_from_manifest(ctx, &dist, manifest_path).with_context(|| {
format!(
"publish-only: failed to load {} from {}. The preserve-dist \
flow normally copies these from the harness's worktree post-pipeline; \
if any is missing the preserved dist is incomplete.",
manifest_path.display(),
dist.display()
)
})?;
}
ctx.artifacts.dedupe_targetless_duplicates();
log.status(&format!(
"publish-only: rehydrated {} artifact(s) from {} artifacts manifest(s)",
ctx.artifacts.all().len(),
artifact_manifests.len(),
));
detect_duplicate_artifact_paths(ctx)?;
strip_ephemeral_signatures(ctx, log);
crate::commands::helpers::detect_missing_files(
ctx.artifacts
.all()
.iter()
.filter(|a| {
!matches!(
a.kind,
anodizer_core::artifact::ArtifactKind::Binary
| anodizer_core::artifact::ArtifactKind::UniversalBinary
| anodizer_core::artifact::ArtifactKind::Metadata
)
})
.map(|a| a.path.as_path()),
&dist,
)?;
let p = pipeline::build_publish_only_pipeline();
let result = p.run(ctx, log);
if result.is_ok() {
super::run_post_pipeline(ctx, config, opts.dry_run, log)?;
cleanup_shard_manifests(&dist, log);
}
if result.is_ok() {
super::gate_required_failures(ctx)?;
}
result
}
fn preflight_credentials(env: impl Fn(&str) -> Option<String>) -> Result<()> {
let token_present = GITHUB_TOKEN_ENV_VARS
.iter()
.any(|v| env(v).map(|s| !s.is_empty()).unwrap_or(false));
let sign_key_present = SIGN_ENV_VARS
.iter()
.any(|v| env(v).map(|s| !s.is_empty()).unwrap_or(false));
if !token_present {
anyhow::bail!(
"publish-only: missing release token. Set one of {} before running --publish-only \
(or pass --dry-run to preview without secrets).",
GITHUB_TOKEN_ENV_VARS.join(" / "),
);
}
if !sign_key_present {
anyhow::bail!(
"publish-only: missing production signing key. Set at least one of {} before \
running --publish-only (or pass --dry-run to preview without secrets). \
The harness's ephemeral signatures are NOT shippable — this mode exists \
to overlay production signatures on the byte-stable artifacts.",
SIGN_ENV_VARS.join(" / "),
);
}
Ok(())
}
fn strip_ephemeral_signatures(ctx: &mut Context, log: &StageLogger) {
use anodizer_core::artifact::ArtifactKind;
let stale_paths: Vec<std::path::PathBuf> = ctx
.artifacts
.all()
.iter()
.filter(|a| matches!(a.kind, ArtifactKind::Signature | ArtifactKind::Certificate))
.map(|a| a.path.clone())
.collect();
if stale_paths.is_empty() {
return;
}
let count = stale_paths.len();
log.status(&format!(
"publish-only: stripping {count} ephemeral signature/certificate artifact(s) before re-sign"
));
ctx.artifacts.remove_by_paths(&stale_paths);
let mut disk_removed = 0usize;
for p in &stale_paths {
match std::fs::remove_file(p) {
Ok(()) => disk_removed += 1,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => log.warn(&format!(
"publish-only: failed to delete stale signature {}: {} \
(continuing; SignStage will overwrite or fail loudly)",
p.display(),
e
)),
}
}
log.status(&format!(
"publish-only: stripped {count} ephemeral signature artifact(s) from registry \
({disk_removed} also deleted from disk)"
));
}
fn detect_duplicate_artifact_paths(ctx: &Context) -> Result<()> {
crate::commands::helpers::detect_duplicate_paths(
ctx.artifacts.all().iter().map(|a| a.path.as_path()),
)
}
#[derive(serde::Deserialize, Debug, Default, Clone)]
struct PreservedDistContext {
#[serde(default)]
artifacts: Vec<PreservedArtifact>,
#[serde(default)]
targets: Vec<String>,
#[serde(default)]
version: String,
#[serde(default)]
commit: String,
}
#[derive(serde::Deserialize, Debug, Default, Clone)]
struct PreservedArtifact {
#[serde(default)]
name: String,
#[serde(default)]
path: String,
#[serde(default)]
sha256: String,
#[serde(default)]
size: u64,
}
fn discover_sharded_manifests(dist: &Path, base: &str) -> Result<Vec<PathBuf>> {
let entries = std::fs::read_dir(dist).with_context(|| {
format!(
"publish-only: reading dist directory {} to discover {} manifest(s)",
dist.display(),
base,
)
})?;
let exact = format!("{base}.json");
let prefix = format!("{base}-");
let mut found: Vec<PathBuf> = Vec::new();
for entry in entries {
let entry = entry.with_context(|| {
format!(
"publish-only: reading directory entry under {}",
dist.display()
)
})?;
if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
continue;
}
let name = entry.file_name();
let name = match name.to_str() {
Some(n) => n,
None => continue,
};
if name.ends_with(".tmp") {
continue;
}
if name == exact || (name.starts_with(&prefix) && name.ends_with(".json")) {
found.push(entry.path());
}
}
found.sort();
Ok(found)
}
fn discover_preserved_contexts(dist: &Path) -> Result<Vec<(PathBuf, PreservedDistContext)>> {
let found = discover_sharded_manifests(dist, "context")?;
if found.is_empty() {
anyhow::bail!(
"publish-only: no context.json (or context-<shard>.json) found at {}. \
Run `anodize check determinism --preserve-dist=<dist-dir>` on a green \
determinism check first, or use `anodize publish` (no sign step) if \
you only need the publisher pass.",
dist.display()
);
}
let mut out: Vec<(PathBuf, PreservedDistContext)> = Vec::with_capacity(found.len());
for path in found {
let parsed = load_preserved_context(&path)?;
out.push((path, parsed));
}
Ok(out)
}
fn discover_artifacts_manifests(dist: &Path) -> Result<Vec<PathBuf>> {
discover_sharded_manifests(dist, "artifacts")
}
fn check_no_unsuffixed_suffixed_collision(dist: &Path, base: &str) -> Result<()> {
let unsuffixed = dist.join(format!("{base}.json"));
if !unsuffixed.is_file() {
return Ok(());
}
let entries = std::fs::read_dir(dist).with_context(|| {
format!(
"publish-only: scanning {} for sharded {} manifests",
dist.display(),
base,
)
})?;
let prefix = format!("{base}-");
let mut sharded: Vec<PathBuf> = Vec::new();
for entry in entries {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
continue;
}
let name = entry.file_name();
let name = match name.to_str() {
Some(n) => n,
None => continue,
};
if name.ends_with(".tmp") {
continue;
}
if name.starts_with(&prefix) && name.ends_with(".json") {
sharded.push(entry.path());
}
}
if !sharded.is_empty() {
sharded.sort();
let sharded_display = sharded
.iter()
.map(|p| {
p.file_name()
.and_then(|n| n.to_str())
.unwrap_or("<?>")
.to_string()
})
.collect::<Vec<_>>()
.join(", ");
anyhow::bail!(
"publish-only: both {base}.json AND sharded {base}-*.json ({sharded_display}) \
exist at {dist}. This indicates upload-artifact merged shards' \
un-suffixed {base}.json files over each other before they were \
properly suffixed — the surviving {base}.json is only one shard's view. \
Either delete the un-suffixed {base}.json (if the sharded files are \
authoritative) or delete the sharded files (legacy single-shard mode).",
base = base,
sharded_display = sharded_display,
dist = dist.display(),
);
}
Ok(())
}
fn merge_preserved_contexts(
contexts: &[(PathBuf, PreservedDistContext)],
) -> Result<PreservedDistContext> {
use std::collections::BTreeSet;
let mut merged = PreservedDistContext::default();
let mut targets: BTreeSet<String> = BTreeSet::new();
for (_, c) in contexts {
if merged.version.is_empty() && !c.version.is_empty() {
merged.version = c.version.clone();
}
if merged.commit.is_empty() && !c.commit.is_empty() {
merged.commit = c.commit.clone();
}
for t in &c.targets {
targets.insert(t.clone());
}
for a in &c.artifacts {
merged.artifacts.push(PreservedArtifact {
name: a.name.clone(),
path: a.path.clone(),
sha256: a.sha256.clone(),
size: a.size,
});
}
}
merged.targets = targets.into_iter().collect();
if merged.commit.is_empty() {
anyhow::bail!(
"publish-only: no context manifest carried a `commit` field. Cannot verify the \
preserved bytes match the current release; re-run \
`anodize check determinism --preserve-dist=...` with a producer that \
records the commit SHA."
);
}
for (path, ctx_entry) in contexts {
if !ctx_entry.commit.is_empty() && ctx_entry.commit != merged.commit {
anyhow::bail!(
"publish-only: shard manifest {} records commit {} but the merged set is \
anchored at {}. A multi-shard preserved dist must come from a single \
release attempt; mixing bytes from different commits would publish \
signatures whose determinism-verified state is split.",
path.display(),
short_commit_str(&ctx_entry.commit),
short_commit_str(&merged.commit),
);
}
}
for (path, ctx_entry) in contexts {
if !ctx_entry.version.is_empty() && ctx_entry.version != merged.version {
anyhow::bail!(
"publish-only: shard manifest {} records version {} but the merged set is \
anchored at {}. A multi-shard preserved dist must come from a single \
release attempt; mixing bytes across versions would publish \
signatures whose determinism-verified state is split.",
path.display(),
ctx_entry.version,
merged.version,
);
}
}
Ok(merged)
}
fn load_preserved_context(path: &Path) -> Result<PreservedDistContext> {
if !path.exists() {
anyhow::bail!(
"publish-only: missing {}. Run `anodize check determinism \
--preserve-dist=<dist-dir>` on a green determinism check first, or use \
`anodize publish` (no sign step) if you only need the publisher pass.",
path.display(),
);
}
let bytes =
std::fs::read(path).with_context(|| format!("publish-only: read {}", path.display()))?;
let ctx: PreservedDistContext = serde_json::from_slice(&bytes).with_context(|| {
format!(
"publish-only: parse {} as PreservedDistContext",
path.display()
)
})?;
Ok(ctx)
}
const EPHEMERAL_SIGNATURE_SUFFIXES: &[&str] = &[".sig", ".asc", ".pem"];
fn is_ephemeral_signature_path(path: &str) -> bool {
EPHEMERAL_SIGNATURE_SUFFIXES
.iter()
.any(|suffix| path.ends_with(suffix))
}
fn hash_verify_preserved_dist(ctx: &PreservedDistContext, dist_root: &Path) -> Result<()> {
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::io::Read;
let mut by_path: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for artifact in &ctx.artifacts {
if is_ephemeral_signature_path(&artifact.path) {
continue;
}
by_path
.entry(artifact.path.as_str())
.or_default()
.push(artifact.sha256.as_str());
}
for (path_str, expected_hashes) in &by_path {
let path = dist_root.join(path_str);
let mut file = std::fs::File::open(&path).with_context(|| {
format!(
"publish-only hash-verify: opening preserved artifact {}",
path.display(),
)
})?;
let mut hasher = Sha256::new();
let mut buf = [0u8; 64 * 1024];
loop {
let n = file
.read(&mut buf)
.with_context(|| format!("publish-only hash-verify: reading {}", path.display()))?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
let actual_hex = format!("{:x}", hasher.finalize());
let actual = format!("sha256:{actual_hex}");
let expected_normalized: Vec<String> = expected_hashes
.iter()
.map(|h| {
if h.starts_with("sha256:") {
(*h).to_string()
} else {
format!("sha256:{h}")
}
})
.collect();
let matches_any = expected_normalized.iter().any(|e| e == &actual);
if !matches_any {
let mut distinct: Vec<&String> = expected_normalized.iter().collect();
distinct.sort();
distinct.dedup();
let expected_list = distinct
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ");
anyhow::bail!(
"publish-only hash-verify: bytes on disk diverge from every shard's recorded \
determinism state for {} (recorded across {} shard(s): [{}], on disk: {}). \
The dist tree was modified between determinism check and publish, OR no \
shard's preserved bytes survived `download-artifact merge-multiple` — \
refusing to ship.",
path.display(),
expected_normalized.len(),
expected_list,
actual,
);
}
}
Ok(())
}
fn cleanup_shard_manifests(dist: &Path, log: &StageLogger) {
let base = "artifacts";
let entries = match std::fs::read_dir(dist) {
Ok(e) => e,
Err(e) => {
log.warn(&format!(
"publish-only: failed to read {} for shard-manifest cleanup: {} \
(a retry may trip the unsuffixed-vs-suffixed collision check)",
dist.display(),
e,
));
return;
}
};
let prefix = format!("{base}-");
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = match name.to_str() {
Some(s) => s,
None => continue,
};
if name_str.starts_with(&prefix) && name_str.ends_with(".json") {
let path = entry.path();
if let Err(e) = std::fs::remove_file(&path) {
log.warn(&format!(
"publish-only: failed to remove shard manifest {}: {} \
(a retry may trip the unsuffixed-vs-suffixed collision check)",
path.display(),
e
));
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn env_from(map: HashMap<&str, &str>) -> impl Fn(&str) -> Option<String> {
let owned: HashMap<String, String> = map
.into_iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
move |k| owned.get(k).cloned()
}
#[test]
fn load_preserved_context_rejects_missing_file() {
let tmp = tempfile::tempdir().unwrap();
let err = load_preserved_context(&tmp.path().join("context.json")).unwrap_err();
let msg = format!("{:#}", err);
assert!(
msg.contains("publish-only: missing"),
"error should name the publish-only path; got: {msg}"
);
assert!(
msg.contains("--preserve-dist"),
"error should point at the preserve-dist flag; got: {msg}"
);
assert!(
msg.contains("<dist-dir>"),
"error should use the literal <dist-dir> placeholder; got: {msg}"
);
}
#[test]
fn load_preserved_context_parses_minimal_json() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("context.json");
std::fs::write(
&path,
r#"{"artifacts":[{"name":"a.tar.gz","path":"a.tar.gz","sha256":"sha256:abc","size":42}],"targets":["x86_64-unknown-linux-gnu"],"version":"0.1.0","commit":"deadbeefcafe"}"#,
)
.unwrap();
let parsed = load_preserved_context(&path).unwrap();
assert_eq!(parsed.version, "0.1.0");
assert_eq!(parsed.commit, "deadbeefcafe");
assert_eq!(parsed.targets, vec!["x86_64-unknown-linux-gnu"]);
assert_eq!(parsed.artifacts.len(), 1);
assert_eq!(parsed.artifacts[0].name, "a.tar.gz");
}
#[test]
fn load_preserved_context_tolerates_missing_fields() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("context.json");
std::fs::write(&path, r#"{}"#).unwrap();
let parsed = load_preserved_context(&path).unwrap();
assert!(parsed.artifacts.is_empty());
assert!(parsed.targets.is_empty());
assert_eq!(parsed.version, "");
assert_eq!(parsed.commit, "");
}
#[test]
fn preflight_credentials_bails_when_token_missing() {
let err = preflight_credentials(|_| None).unwrap_err();
assert!(
format!("{err}").contains("missing release token"),
"expected missing-token error; got: {err}"
);
}
#[test]
fn preflight_credentials_bails_when_sign_key_missing() {
let env = env_from(HashMap::from([("GITHUB_TOKEN", "x")]));
let err = preflight_credentials(env).unwrap_err();
assert!(
format!("{err}").contains("missing production signing key"),
"expected missing-sign-key error after token set; got: {err}"
);
}
#[test]
fn preflight_credentials_accepts_token_and_cosign_key() {
let env = env_from(HashMap::from([("GITHUB_TOKEN", "x"), ("COSIGN_KEY", "y")]));
preflight_credentials(env).expect("token + cosign should preflight clean");
}
#[test]
fn preflight_credentials_accepts_anodizer_github_token_alias() {
let env = env_from(HashMap::from([
("ANODIZER_GITHUB_TOKEN", "x"),
("GPG_PRIVATE_KEY", "y"),
]));
preflight_credentials(env).expect("anodizer github token + gpg key should preflight clean");
}
#[test]
fn preflight_credentials_rejects_empty_token_value() {
let env = env_from(HashMap::from([("GITHUB_TOKEN", ""), ("COSIGN_KEY", "y")]));
let err = preflight_credentials(env).unwrap_err();
assert!(
format!("{err}").contains("missing release token"),
"empty token must be treated as missing; got: {err}"
);
}
#[test]
fn discover_sharded_manifests_skips_tmp_siblings_uniformly() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("context.json"), "{}").unwrap();
std::fs::write(tmp.path().join("context.json.tmp"), "garbage").unwrap();
std::fs::write(tmp.path().join("artifacts.json"), "[]").unwrap();
std::fs::write(tmp.path().join("artifacts.json.tmp"), "garbage").unwrap();
std::fs::write(tmp.path().join("artifacts-linux.json"), "[]").unwrap();
std::fs::write(tmp.path().join("artifacts-linux.json.tmp"), "garbage").unwrap();
let ctx = discover_sharded_manifests(tmp.path(), "context").unwrap();
let names: Vec<String> = ctx
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().to_string())
.collect();
assert_eq!(names, vec!["context.json"], "tmp siblings must be skipped");
let arts = discover_sharded_manifests(tmp.path(), "artifacts").unwrap();
let names: Vec<String> = arts
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().to_string())
.collect();
assert_eq!(
names,
vec!["artifacts-linux.json", "artifacts.json"],
"artifacts family must also skip .tmp; got {names:?}"
);
}
#[test]
fn collision_check_errors_when_unsuffixed_and_suffixed_both_present_context() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("context.json"), "{}").unwrap();
std::fs::write(tmp.path().join("context-linux.json"), "{}").unwrap();
let err = check_no_unsuffixed_suffixed_collision(tmp.path(), "context").unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("context.json") && msg.contains("context-linux.json"),
"error should name both colliding manifests; got: {msg}"
);
assert!(
msg.contains("upload-artifact merged"),
"error should name the symptom hypothesis; got: {msg}"
);
}
#[test]
fn collision_check_errors_when_unsuffixed_and_suffixed_both_present_artifacts() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("artifacts.json"), "[]").unwrap();
std::fs::write(tmp.path().join("artifacts-darwin.json"), "[]").unwrap();
let err = check_no_unsuffixed_suffixed_collision(tmp.path(), "artifacts").unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("artifacts.json") && msg.contains("artifacts-darwin.json"),
"error should name both colliding manifests; got: {msg}"
);
}
#[test]
fn collision_check_ok_for_unsuffixed_alone() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("context.json"), "{}").unwrap();
check_no_unsuffixed_suffixed_collision(tmp.path(), "context")
.expect("unsuffixed-only must be fine");
}
#[test]
fn collision_check_ok_for_suffixed_only() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("context-a.json"), "{}").unwrap();
std::fs::write(tmp.path().join("context-b.json"), "{}").unwrap();
check_no_unsuffixed_suffixed_collision(tmp.path(), "context")
.expect("suffixed-only must be fine");
}
#[test]
fn collision_check_ignores_tmp_sibling_of_suffixed() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("context.json"), "{}").unwrap();
std::fs::write(tmp.path().join("context-linux.json.tmp"), "garbage").unwrap();
check_no_unsuffixed_suffixed_collision(tmp.path(), "context")
.expect(".tmp sibling must not trigger collision");
}
fn ctx_entry(version: &str, commit: &str) -> PreservedDistContext {
PreservedDistContext {
artifacts: vec![],
targets: vec![],
version: version.to_string(),
commit: commit.to_string(),
}
}
#[test]
fn merge_preserved_contexts_bails_when_commit_empty_everywhere() {
let contexts = vec![
(PathBuf::from("context-a.json"), ctx_entry("0.1.0", "")),
(PathBuf::from("context-b.json"), ctx_entry("0.1.0", "")),
];
let err = merge_preserved_contexts(&contexts).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("no context manifest carried a `commit`"),
"expected commit-missing diagnostic; got: {msg}"
);
}
#[test]
fn merge_preserved_contexts_bails_on_commit_mismatch_across_shards() {
let contexts = vec![
(
PathBuf::from("context-a.json"),
ctx_entry("0.1.0", "deadbeefcafe"),
),
(
PathBuf::from("context-b.json"),
ctx_entry("0.1.0", "ba5eba11feed"),
),
];
let err = merge_preserved_contexts(&contexts).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("records commit") && msg.contains("merged set is"),
"expected per-shard commit-mismatch diagnostic; got: {msg}"
);
assert!(
msg.contains("context-b.json"),
"diagnostic must name the dissenting shard; got: {msg}"
);
}
#[test]
fn merge_preserved_contexts_bails_on_version_mismatch_across_shards() {
let contexts = vec![
(
PathBuf::from("context-a.json"),
ctx_entry("0.1.0", "deadbeefcafe"),
),
(
PathBuf::from("context-b.json"),
ctx_entry("0.2.0", "deadbeefcafe"),
),
];
let err = merge_preserved_contexts(&contexts).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("records version") && msg.contains("merged set is"),
"expected per-shard version-mismatch diagnostic; got: {msg}"
);
assert!(
msg.contains("context-b.json"),
"diagnostic must name the dissenting shard; got: {msg}"
);
}
#[test]
fn merge_preserved_contexts_accepts_consistent_shards() {
let contexts = vec![
(
PathBuf::from("context-a.json"),
ctx_entry("0.1.0", "deadbeefcafe"),
),
(
PathBuf::from("context-b.json"),
ctx_entry("0.1.0", "deadbeefcafe"),
),
];
let merged = merge_preserved_contexts(&contexts).expect("consistent shards must merge");
assert_eq!(merged.commit, "deadbeefcafe");
assert_eq!(merged.version, "0.1.0");
}
#[test]
fn merge_preserved_contexts_tolerates_one_shard_with_empty_commit() {
let contexts = vec![
(PathBuf::from("context-a.json"), ctx_entry("0.1.0", "")),
(
PathBuf::from("context-b.json"),
ctx_entry("0.1.0", "deadbeefcafe"),
),
];
let merged = merge_preserved_contexts(&contexts).expect("mixed-empty shards must merge");
assert_eq!(merged.commit, "deadbeefcafe");
}
#[test]
fn detect_duplicate_paths_in_passes_on_unique_set() {
let paths = [Path::new("a.tar.gz"), Path::new("b.tar.gz")];
crate::commands::helpers::detect_duplicate_paths(paths).expect("unique paths must pass");
}
#[test]
fn detect_duplicate_paths_in_flags_repeated_path() {
let paths = [
Path::new("a.tar.gz"),
Path::new("b.tar.gz"),
Path::new("a.tar.gz"),
];
let err = crate::commands::helpers::detect_duplicate_paths(paths).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("a.tar.gz"),
"error must name the duplicated path; got: {msg}"
);
assert!(
msg.contains("(2×)"),
"error must show the duplicate count; got: {msg}"
);
assert!(
msg.contains("shards overlapped"),
"error must name the matrix-overlap hypothesis; got: {msg}"
);
}
#[test]
fn detect_missing_files_in_passes_when_all_present() {
let tmp = tempfile::tempdir().unwrap();
let a = tmp.path().join("a.tar.gz");
std::fs::write(&a, b"x").unwrap();
std::fs::write(tmp.path().join("rel.tar.gz"), b"x").unwrap();
let paths = [a.as_path(), Path::new("rel.tar.gz")];
crate::commands::helpers::detect_missing_files(paths, tmp.path())
.expect("all present must pass");
}
#[test]
fn detect_missing_files_in_errors_on_absent_absolute_path() {
let tmp = tempfile::tempdir().unwrap();
let missing = tmp.path().join("does-not-exist.tar.gz");
let paths = [missing.as_path()];
let err = crate::commands::helpers::detect_missing_files(paths, tmp.path()).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("does-not-exist.tar.gz"),
"error must name the missing file; got: {msg}"
);
assert!(
msg.contains("preserved dist is incomplete"),
"error must surface the incomplete-dist hypothesis; got: {msg}"
);
}
#[test]
fn detect_missing_files_in_errors_on_absent_relative_path() {
let tmp = tempfile::tempdir().unwrap();
let paths = [Path::new("rel-missing.tar.gz")];
let err = crate::commands::helpers::detect_missing_files(paths, tmp.path()).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("rel-missing.tar.gz"),
"error must name the missing relative file; got: {msg}"
);
}
#[test]
fn detect_missing_files_in_ignores_files_not_in_manifest() {
let tmp = tempfile::tempdir().unwrap();
let a = tmp.path().join("a.tar.gz");
std::fs::write(&a, b"x").unwrap();
std::fs::write(tmp.path().join("metadata.json"), b"{}").unwrap();
std::fs::write(tmp.path().join("orphan.tar.gz"), b"x").unwrap();
let paths = [a.as_path()];
crate::commands::helpers::detect_missing_files(paths, tmp.path())
.expect("unreferenced dist files must not trigger the check");
}
const HELLO_WORLD_SHA256: &str =
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
#[test]
fn hash_verify_preserved_dist_accepts_matching_bytes() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("hello.txt"), b"hello world").unwrap();
let ctx = PreservedDistContext {
artifacts: vec![PreservedArtifact {
name: "hello.txt".into(),
path: "hello.txt".into(),
sha256: format!("sha256:{HELLO_WORLD_SHA256}"),
size: 11,
}],
..PreservedDistContext::default()
};
hash_verify_preserved_dist(&ctx, tmp.path()).expect("matching bytes must verify clean");
}
#[test]
fn hash_verify_preserved_dist_rejects_mismatched_bytes() {
let tmp = tempfile::tempdir().unwrap();
let rel = "hello.txt";
std::fs::write(tmp.path().join(rel), b"hello world").unwrap();
let ctx = PreservedDistContext {
artifacts: vec![PreservedArtifact {
name: rel.into(),
path: rel.into(),
sha256: "sha256:0000000000000000000000000000000000000000000000000000000000000000"
.into(),
size: 11,
}],
..PreservedDistContext::default()
};
let err = hash_verify_preserved_dist(&ctx, tmp.path()).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("diverge"),
"error must surface the divergence wording; got: {msg}"
);
assert!(
msg.contains(rel),
"error must name the offending file; got: {msg}"
);
}
#[test]
fn hash_verify_preserved_dist_skips_ephemeral_signatures() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("foo.tar.gz.sha256.sig"), b"shard-A-bytes").unwrap();
let ctx = PreservedDistContext {
artifacts: vec![PreservedArtifact {
name: "foo.tar.gz.sha256.sig".into(),
path: "foo.tar.gz.sha256.sig".into(),
sha256: format!("sha256:{HELLO_WORLD_SHA256}"),
size: 13,
}],
..PreservedDistContext::default()
};
hash_verify_preserved_dist(&ctx, tmp.path())
.expect("ephemeral .sig paths must skip hash-verify");
}
#[test]
fn hash_verify_preserved_dist_skips_pem_and_asc() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("foo.pem"), b"cert-A").unwrap();
std::fs::write(tmp.path().join("foo.asc"), b"asc-A").unwrap();
let ctx = PreservedDistContext {
artifacts: vec![
PreservedArtifact {
name: "foo.pem".into(),
path: "foo.pem".into(),
sha256: format!("sha256:{HELLO_WORLD_SHA256}"),
size: 6,
},
PreservedArtifact {
name: "foo.asc".into(),
path: "foo.asc".into(),
sha256: format!("sha256:{HELLO_WORLD_SHA256}"),
size: 5,
},
],
..PreservedDistContext::default()
};
hash_verify_preserved_dist(&ctx, tmp.path())
.expect("ephemeral .pem / .asc paths must skip hash-verify");
}
#[test]
fn hash_verify_preserved_dist_accepts_when_any_shard_matches_disk() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("source.tar.gz"), b"hello world").unwrap();
let ctx = PreservedDistContext {
artifacts: vec![
PreservedArtifact {
name: "source.tar.gz".into(),
path: "source.tar.gz".into(),
sha256:
"sha256:0000000000000000000000000000000000000000000000000000000000000000"
.into(),
size: 11,
},
PreservedArtifact {
name: "source.tar.gz".into(),
path: "source.tar.gz".into(),
sha256: format!("sha256:{HELLO_WORLD_SHA256}"),
size: 11,
},
PreservedArtifact {
name: "source.tar.gz".into(),
path: "source.tar.gz".into(),
sha256:
"sha256:1111111111111111111111111111111111111111111111111111111111111111"
.into(),
size: 11,
},
],
..PreservedDistContext::default()
};
hash_verify_preserved_dist(&ctx, tmp.path())
.expect("cross-shard duplicate must verify when any shard's hash matches disk");
}
#[test]
fn hash_verify_preserved_dist_bails_when_no_shard_matches_disk() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("source.tar.gz"), b"hello world").unwrap();
let ctx = PreservedDistContext {
artifacts: vec![
PreservedArtifact {
name: "source.tar.gz".into(),
path: "source.tar.gz".into(),
sha256:
"sha256:0000000000000000000000000000000000000000000000000000000000000000"
.into(),
size: 11,
},
PreservedArtifact {
name: "source.tar.gz".into(),
path: "source.tar.gz".into(),
sha256:
"sha256:1111111111111111111111111111111111111111111111111111111111111111"
.into(),
size: 11,
},
],
..PreservedDistContext::default()
};
let err = hash_verify_preserved_dist(&ctx, tmp.path()).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("recorded across 2 shard(s)"),
"error must surface the shard count; got: {msg}"
);
assert!(
msg.contains("source.tar.gz"),
"error must name the offending file; got: {msg}"
);
}
#[test]
fn hash_verify_preserved_dist_rejects_missing_file() {
let tmp = tempfile::tempdir().unwrap();
let ctx = PreservedDistContext {
artifacts: vec![PreservedArtifact {
name: "absent.tar.gz".into(),
path: "absent.tar.gz".into(),
sha256: format!("sha256:{HELLO_WORLD_SHA256}"),
size: 11,
}],
..PreservedDistContext::default()
};
let err = hash_verify_preserved_dist(&ctx, tmp.path()).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("opening preserved artifact"),
"error must surface the open-failure wording; got: {msg}"
);
assert!(
msg.contains("absent.tar.gz"),
"error must name the missing file; got: {msg}"
);
}
#[test]
fn cleanup_shard_manifests_removes_only_artifacts_shards_leaves_context() {
use anodizer_core::log::Verbosity;
let tmp = tempfile::tempdir().unwrap();
let dist = tmp.path();
std::fs::write(dist.join("artifacts.json"), b"[]").unwrap();
std::fs::write(dist.join("artifacts-ubuntu-latest.json"), b"[]").unwrap();
std::fs::write(dist.join("artifacts-macos-latest.json"), b"[]").unwrap();
std::fs::write(dist.join("artifacts-windows-x86_64.json"), b"[]").unwrap();
std::fs::write(dist.join("context-ubuntu-latest.json"), b"{}").unwrap();
std::fs::write(dist.join("context-macos-latest.json"), b"{}").unwrap();
let log = StageLogger::new("test", Verbosity::Quiet);
cleanup_shard_manifests(dist, &log);
assert!(dist.join("artifacts.json").is_file());
assert!(!dist.join("artifacts-ubuntu-latest.json").exists());
assert!(!dist.join("artifacts-macos-latest.json").exists());
assert!(!dist.join("artifacts-windows-x86_64.json").exists());
assert!(dist.join("context-ubuntu-latest.json").is_file());
assert!(dist.join("context-macos-latest.json").is_file());
}
#[test]
fn publish_only_run_suppresses_binary_signs_with_warn() {
use anodizer_core::config::SignConfig;
let mut binary_signs: Vec<SignConfig> = vec![
SignConfig {
id: Some("cosign-binary".into()),
..Default::default()
},
SignConfig {
id: Some("cosign-binary-2".into()),
..Default::default()
},
];
assert_eq!(binary_signs.len(), 2);
if !binary_signs.is_empty() {
binary_signs.clear();
}
assert!(binary_signs.is_empty());
}
#[test]
fn missing_file_check_skips_binary_and_universal_binary_kinds() {
use anodizer_core::artifact::{Artifact, ArtifactKind};
use anodizer_core::context::{Context, ContextOptions};
let config = Config::default();
let mut ctx = Context::new(config, ContextOptions::default());
let kinds = [
ArtifactKind::Binary,
ArtifactKind::UniversalBinary,
ArtifactKind::Archive,
ArtifactKind::Checksum,
];
for (i, k) in kinds.iter().enumerate() {
ctx.artifacts.add(Artifact {
kind: *k,
name: format!("art-{i}"),
path: std::path::PathBuf::from(format!("art-{i}")),
target: None,
crate_name: String::new(),
metadata: Default::default(),
size: None,
});
}
let kept: Vec<ArtifactKind> = ctx
.artifacts
.all()
.iter()
.filter(|a| !matches!(a.kind, ArtifactKind::Binary | ArtifactKind::UniversalBinary))
.map(|a| a.kind)
.collect();
assert_eq!(kept, vec![ArtifactKind::Archive, ArtifactKind::Checksum]);
}
}