use super::*;
use anodizer_core::log::StageLogger;
use anyhow::Result;
use std::path::{Path, PathBuf};
#[derive(serde::Deserialize, Debug, Default, Clone)]
pub(super) struct PreservedDistContext {
#[serde(default)]
pub(super) artifacts: Vec<PreservedArtifact>,
#[serde(default)]
pub(super) targets: Vec<String>,
#[serde(default)]
pub(super) version: String,
#[serde(default)]
pub(super) commit: String,
}
#[derive(serde::Deserialize, Debug, Default, Clone)]
pub(super) struct PreservedArtifact {
#[serde(default)]
pub(super) name: String,
#[serde(default)]
pub(super) path: String,
#[serde(default)]
pub(super) sha256: String,
#[serde(default)]
pub(super) size: u64,
}
pub(super) 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 stem = base.strip_suffix(".json").unwrap_or(base);
let exact = base;
let prefix = format!("{stem}-");
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)
}
pub(super) fn discover_preserved_contexts(
dist: &Path,
) -> Result<Vec<(PathBuf, PreservedDistContext)>> {
let found = discover_sharded_manifests(dist, anodizer_core::dist::CONTEXT_JSON)?;
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)
}
pub(super) fn discover_artifacts_manifests(dist: &Path) -> Result<Vec<PathBuf>> {
discover_sharded_manifests(dist, anodizer_core::dist::ARTIFACTS_JSON)
}
pub(super) 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(())
}
pub(super) 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)
}
pub(super) 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)
}
pub(super) const EPHEMERAL_SIGNATURE_SUFFIXES: &[&str] = &[".sig", ".asc", ".pem"];
pub(super) fn is_ephemeral_signature_path(path: &str) -> bool {
EPHEMERAL_SIGNATURE_SUFFIXES
.iter()
.any(|suffix| path.ends_with(suffix))
}
pub(super) fn hash_verify_preserved_dist(
ctx: &PreservedDistContext,
dist_root: &Path,
) -> Result<()> {
use std::collections::BTreeMap;
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 actual_hex = anodizer_core::hashing::sha256_file(&path).with_context(|| {
format!(
"publish-only hash-verify: hashing preserved artifact {}",
path.display(),
)
})?;
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(())
}
pub(super) 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!(
"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!(
"failed to remove shard manifest {}: {} \
(a retry may trip the unsuffixed-vs-suffixed collision check)",
path.display(),
e
));
}
}
}
}