use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct HomebrewTargetSnapshot {
pub target: String,
pub repo_url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_env_var: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct HomebrewExtra {
pub homebrew_targets: Vec<HomebrewTargetSnapshot>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct ScoopTargetSnapshot {
pub target: String,
pub repo_url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_env_var: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct ScoopExtra {
pub scoop_targets: Vec<ScoopTargetSnapshot>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct NixTargetSnapshot {
pub target: String,
pub repo_url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_env_var: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct NixExtra {
pub nix_targets: Vec<NixTargetSnapshot>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct WingetTargetSnapshot {
pub target: String,
pub crate_name: String,
pub package_id: String,
pub version: String,
pub upstream_owner: String,
pub upstream_repo: String,
pub fork_owner: String,
pub branch: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct WingetExtra {
pub winget_targets: Vec<WingetTargetSnapshot>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct ChocolateyTargetSnapshot {
pub target: String,
pub crate_name: String,
pub package_id: String,
pub version: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct ChocolateyExtra {
pub chocolatey_targets: Vec<ChocolateyTargetSnapshot>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct KrewTargetSnapshot {
pub target: String,
pub upstream_owner: String,
pub upstream_repo: String,
pub fork_owner: String,
pub branch: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_env_var: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct KrewExtra {
pub krew_targets: Vec<KrewTargetSnapshot>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct AurTargetSnapshot {
pub target: String,
pub git_url: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct AurExtra {
pub aur_our_targets: Vec<AurTargetSnapshot>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct AurSourceTargetSnapshot {
pub target: String,
pub package: String,
pub tag: String,
pub git_url: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct AurSourceExtra {
pub aur_source_targets: Vec<AurSourceTargetSnapshot>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct McpTargetSnapshot {
pub target: String,
pub server_name: String,
pub registry_url: String,
pub version: String,
pub auth_method: crate::config::McpAuthMethod,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct McpExtra {
pub mcp_targets: Vec<McpTargetSnapshot>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct DockerhubTargetSnapshot {
pub target: String,
pub repo_url: String,
pub namespace: String,
pub name: String,
pub username: String,
pub secret_env_var: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snapshot_description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snapshot_full_description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct DockerhubExtra {
pub dockerhub_targets: Vec<DockerhubTargetSnapshot>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct CargoYankTargetSnapshot {
pub name: String,
pub version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub registry: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub index: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct CargoExtra {
pub cargo_yank_targets: Vec<CargoYankTargetSnapshot>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct ArtifactoryTargetSnapshot {
pub entry: String,
pub url: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct ArtifactoryExtra {
pub artifactory_targets: Vec<ArtifactoryTargetSnapshot>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct CloudsmithTargetSnapshot {
pub org: String,
pub repo: String,
pub filename: String,
#[serde(default)]
pub slug: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct CloudsmithExtra {
pub cloudsmith_targets: Vec<CloudsmithTargetSnapshot>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct BlobTargetSnapshot {
pub provider: String,
pub bucket: String,
pub key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub region: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub endpoint: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct BlobExtra {
pub blob_targets: Vec<BlobTargetSnapshot>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct SnapcraftTargetSnapshot {
pub crate_name: String,
pub package_name: String,
#[serde(default)]
pub channel: Option<String>,
#[serde(default)]
pub revision: Option<String>,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub held_for_review: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct SnapcraftExtra {
pub snapcraft_targets: Vec<SnapcraftTargetSnapshot>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct GithubReleaseTargetSnapshot {
pub crate_name: String,
pub owner: String,
pub repo: String,
pub tag: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub release_id: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct GithubReleaseExtra {
pub github_release_targets: Vec<GithubReleaseTargetSnapshot>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct NpmTargetSnapshot {
pub target: String,
pub package: String,
pub version: String,
pub registry: String,
pub dist_tag: String,
pub token_env_var: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct NpmExtra {
pub npm_targets: Vec<NpmTargetSnapshot>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct GemFuryTargetSnapshot {
pub target: String,
pub account: String,
pub package: String,
pub version: String,
pub format: String,
pub push_token_env_var: String,
pub api_token_env_var: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct GemFuryExtra {
pub gemfury_targets: Vec<GemFuryTargetSnapshot>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct SchemastoreTargetSnapshot {
pub upstream_owner: String,
pub upstream_repo: String,
pub fork_owner: String,
pub branch: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_env_var: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct SchemastoreExtra {
pub schemastore_targets: Vec<SchemastoreTargetSnapshot>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(untagged)]
pub enum PublishEvidenceExtra {
Homebrew(HomebrewExtra),
Scoop(ScoopExtra),
Nix(NixExtra),
Winget(WingetExtra),
Chocolatey(ChocolateyExtra),
Krew(KrewExtra),
Aur(AurExtra),
AurSource(AurSourceExtra),
Mcp(McpExtra),
Dockerhub(DockerhubExtra),
Cargo(CargoExtra),
Artifactory(ArtifactoryExtra),
Cloudsmith(CloudsmithExtra),
Blob(BlobExtra),
Snapcraft(SnapcraftExtra),
GithubRelease(GithubReleaseExtra),
Npm(NpmExtra),
GemFury(GemFuryExtra),
Schemastore(SchemastoreExtra),
#[default]
Empty,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PublishEvidence {
pub schema_version: u32,
pub publisher: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub primary_ref: Option<String>,
pub artifact_paths: Vec<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nondeterministic: Option<String>,
#[serde(default, deserialize_with = "deserialize_extra_compat")]
pub extra: PublishEvidenceExtra,
}
fn deserialize_extra_compat<'de, D>(deserializer: D) -> Result<PublishEvidenceExtra, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
if value.is_null() {
return Ok(PublishEvidenceExtra::Empty);
}
if let Some(map) = value.as_object()
&& map.is_empty()
{
return Ok(PublishEvidenceExtra::Empty);
}
serde_json::from_value(value).map_err(serde::de::Error::custom)
}
impl PublishEvidence {
pub const CURRENT_SCHEMA_VERSION: u32 = 2;
pub fn new(publisher: impl Into<String>) -> Self {
Self {
schema_version: Self::CURRENT_SCHEMA_VERSION,
publisher: publisher.into(),
primary_ref: None,
artifact_paths: Vec::new(),
nondeterministic: None,
extra: PublishEvidenceExtra::Empty,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn publish_evidence_roundtrips_through_json() {
let mut e = PublishEvidence::new("homebrew");
e.primary_ref = Some("refs/heads/main".to_string());
e.artifact_paths.push(PathBuf::from("dist/foo.tar.gz"));
e.nondeterministic = Some("timestamp".to_string());
e.extra = PublishEvidenceExtra::Homebrew(HomebrewExtra {
homebrew_targets: vec![HomebrewTargetSnapshot {
target: "demo".into(),
repo_url: "https://github.com/acme/homebrew-tap.git".into(),
branch: Some("main".into()),
token_env_var: Some("HOMEBREW_TAP_TOKEN".into()),
}],
});
let s = serde_json::to_string(&e).expect("serialize");
let back: PublishEvidence = serde_json::from_str(&s).expect("deserialize");
assert_eq!(e, back);
}
#[test]
fn cargo_extra_roundtrips_and_omits_none_registry_index() {
let mut e = PublishEvidence::new("cargo");
e.extra = PublishEvidenceExtra::Cargo(CargoExtra {
cargo_yank_targets: vec![
CargoYankTargetSnapshot {
name: "crate-a".into(),
version: "1.0.0".into(),
registry: Some("my-registry".into()),
index: None,
},
CargoYankTargetSnapshot {
name: "crate-b".into(),
version: "2.0.0".into(),
registry: None,
index: None,
},
],
});
let s = serde_json::to_string(&e).expect("serialize");
assert!(
s.contains("cargo_yank_targets"),
"wire carries variant key: {s}"
);
assert!(s.contains("my-registry"), "registry preserved: {s}");
assert!(
!s.contains("\"index\""),
"None index omitted on the wire: {s}"
);
let back: PublishEvidence = serde_json::from_str(&s).expect("deserialize");
assert_eq!(e, back);
assert!(matches!(back.extra, PublishEvidenceExtra::Cargo(_)));
}
#[test]
fn publish_evidence_omits_none_fields_on_serialize() {
let e = PublishEvidence::new("homebrew");
let s = serde_json::to_string(&e).expect("serialize");
assert!(
!s.contains("primary_ref"),
"primary_ref should be omitted when None: {s}"
);
assert!(
!s.contains("nondeterministic"),
"nondeterministic should be omitted when None: {s}"
);
let back: PublishEvidence = serde_json::from_str(&s).expect("deserialize");
assert_eq!(e, back);
}
#[test]
fn publish_evidence_rejects_unknown_fields() {
let bad = r#"{
"schema_version": 1,
"publisher": "homebrew",
"primary_ref": null,
"artifact_paths": [],
"nondeterministic": null,
"extra": null,
"future_field": "boom"
}"#;
let r: Result<PublishEvidence, _> = serde_json::from_str(bad);
assert!(r.is_err(), "deny_unknown_fields should reject future_field");
}
#[test]
fn empty_variant_serializes_as_null() {
let e = PublishEvidence::new("homebrew");
let s = serde_json::to_string(&e).expect("serialize");
let v: serde_json::Value = serde_json::from_str(&s).expect("parse");
assert_eq!(v["extra"], serde_json::Value::Null);
}
#[test]
fn empty_variant_deserializes_from_null() {
let from_null = serde_json::from_str::<PublishEvidenceExtra>("null").expect("null");
assert_eq!(from_null, PublishEvidenceExtra::Empty);
}
#[test]
fn krew_extra_roundtrips_pr_direct_targets() {
let extra = KrewExtra {
krew_targets: vec![KrewTargetSnapshot {
target: "mytool".into(),
upstream_owner: "kubernetes-sigs".into(),
upstream_repo: "krew-index".into(),
fork_owner: "acme".into(),
branch: "mytool-v1.2.3".into(),
token_env_var: Some("KREW_INDEX_TOKEN".into()),
}],
};
let json = serde_json::to_string(&extra).expect("serialize");
let back: KrewExtra = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, extra);
}
#[test]
fn krew_extra_empty_targets_serializes() {
let extra = KrewExtra {
krew_targets: vec![],
};
let json = serde_json::to_string(&extra).expect("serialize");
let back: KrewExtra = serde_json::from_str(&json).expect("deserialize");
assert!(back.krew_targets.is_empty());
}
#[test]
fn krew_extra_tolerates_orphan_bot_template_key() {
let blob = r#"{
"krew_targets": [{
"target": "mytool",
"upstream_owner": "kubernetes-sigs",
"upstream_repo": "krew-index",
"fork_owner": "acme",
"branch": "mytool-v1.2.3"
}],
"bot_template_pre_image_shas": {"plugins/mytool.yaml": "deadbeef"}
}"#;
let back: KrewExtra = serde_json::from_str(blob).expect("orphan key must be tolerated");
assert_eq!(back.krew_targets.len(), 1);
assert_eq!(back.krew_targets[0].target, "mytool");
let via_enum: PublishEvidenceExtra =
serde_json::from_str(blob).expect("untagged dispatch must tolerate orphan key");
assert!(matches!(via_enum, PublishEvidenceExtra::Krew(_)));
}
#[test]
fn publish_evidence_schema_version_is_two() {
assert_eq!(PublishEvidence::CURRENT_SCHEMA_VERSION, 2);
}
#[test]
fn publish_evidence_extra_json_shape_matches_pre_typed_form() {
let e = PublishEvidence {
extra: PublishEvidenceExtra::Homebrew(HomebrewExtra {
homebrew_targets: vec![HomebrewTargetSnapshot {
target: "demo".into(),
repo_url: "https://github.com/owner/tap".into(),
branch: Some("anodize-update".into()),
token_env_var: Some("ANODIZER_GITHUB_TOKEN".into()),
}],
}),
..PublishEvidence::new("homebrew")
};
let s = serde_json::to_string(&e).expect("serialize");
let v: serde_json::Value = serde_json::from_str(&s).expect("parse");
let t = &v["extra"]["homebrew_targets"][0];
assert_eq!(t["target"], "demo");
assert_eq!(t["repo_url"], "https://github.com/owner/tap");
assert_eq!(t["branch"], "anodize-update");
assert_eq!(t["token_env_var"], "ANODIZER_GITHUB_TOKEN");
assert!(!s.contains("\"token\":"), "{s}");
assert!(!s.contains("\"password\":"), "{s}");
assert!(!s.contains("\"pat\":"), "{s}");
assert!(!s.contains("\"private_key\":"), "{s}");
assert!(!s.contains("\"secret\":"), "{s}");
assert!(!s.contains("\"api_key\":"), "{s}");
}
}