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)]
#[serde(deny_unknown_fields)]
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 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>,
}
#[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(untagged)]
pub enum PublishEvidenceExtra {
Homebrew(HomebrewExtra),
Scoop(ScoopExtra),
Nix(NixExtra),
Winget(WingetExtra),
Chocolatey(ChocolateyExtra),
Krew(KrewExtra),
Aur(AurExtra),
AurSource(AurSourceExtra),
Mcp(McpExtra),
Dockerhub(DockerhubExtra),
Artifactory(ArtifactoryExtra),
Cloudsmith(CloudsmithExtra),
Blob(BlobExtra),
Snapcraft(SnapcraftExtra),
GithubRelease(GithubReleaseExtra),
#[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 = 1;
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 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 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}");
}
}