anodizer-core 0.5.0

Core configuration, context, and template engine for the anodizer release tool
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
//! Per-publisher run evidence (the `evidence.json` shape).
//!
//! [`PublishEvidence`] captures what a publisher actually pushed plus
//! the operator-public coordinates a later `--rollback-only --from-run`
//! consumes. The [`extra`] slot used to be a free-form
//! `serde_json::Value`; it is now a typed enum
//! ([`PublishEvidenceExtra`]) so the type system structurally
//! prevents credential leakage — a publisher cannot serialize a
//! credential-shaped field into evidence because the variant struct
//! has no such field to hold it.
//!
//! Wire format is preserved: `#[serde(untagged)]` on the enum keeps
//! the rendered JSON identical to the prior free-form
//! `{ "<publisher>_targets": [...] }` shape, so consumers of
//! `dist/run-<id>/report.json` and `summary.json` see the same bytes.
//!
//! [`extra`]: PublishEvidence::extra

use serde::{Deserialize, Serialize};
use std::path::PathBuf;

/// One entry in [`HomebrewExtra::homebrew_targets`] — the operator-public
/// snapshot of a single tap push. Mirrors the serialized field set of
/// `HomebrewTarget` in `stage-publish/src/homebrew/publisher.rs`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct HomebrewTargetSnapshot {
    /// Per-target label — formula name, cask name, or `homebrew_casks`
    /// for the top-level path.
    pub target: String,
    /// HTTPS clone URL of the tap repo.
    pub repo_url: String,
    /// Branch the publish path pushed to. `None` means default.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub branch: Option<String>,
    /// Env var NAME to consult for the rollback re-clone token.
    /// NEVER the token VALUE.
    #[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,
    /// AUR SSH URL — operator-public coordinate.
    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,
    /// MCP auth method — operator-public; carries no credential bytes.
    /// Serializes as `"none"` / `"github"` / `"github-oidc"` per the
    /// rename annotations on [`crate::config::McpAuthMethod`].
    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,
    /// DockerHub login — operator-public.
    pub username: String,
    /// Env var NAME the rollback path consults to re-resolve the password.
    /// NEVER the password VALUE.
    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>,
}

/// Typed `extra` payload for [`PublishEvidence`]. Untagged on the wire —
/// each variant's JSON shape matches the prior free-form
/// `serde_json::json!({"<publisher>_targets": [...]})` form so existing
/// consumers of `dist/run-<id>/report.json` and `summary.json` see no
/// byte-shape change.
///
/// **CREDENTIAL CONTRACT**: every variant's inner struct exposes ONLY
/// operator-public fields. Credential VALUES (token bytes, passwords,
/// SSH key material) have no field to land in — the type system rejects
/// any future leak attempt at the compile boundary. Per-publisher
/// runtime credentials (resolved from env / config at publish time)
/// live in crate-local `*Target` structs with `#[serde(skip)]`
/// discipline; they convert into the snapshots above at the encode
/// boundary, dropping the secret fields by definition.
///
/// The [`Empty`](Self::Empty) variant covers publishers that have no
/// per-evidence operator-public fields (or that no-op'd the run).
/// Serializes as `null` on the wire and is the deserialization
/// fallback for the same shape.
#[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 for publishers with no per-evidence operator-public fields,
    /// or for runs that no-op'd. Serializes as JSON `null`.
    #[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>,
    /// Operator-public metadata for the publisher run.
    ///
    /// **CREDENTIAL CONTRACT**: this field is persisted to
    /// `dist/run-<id>/report.json`, summarised in `summary.json`, and
    /// may be attached to the GitHub Release body via the announce
    /// stage. It carries only operator-public identifiers (URLs,
    /// env-var NAMES, PR numbers, tag strings, branch names). Token
    /// VALUES, private keys, passwords, OAuth secrets, SSH key
    /// material have no variant field to land in — the
    /// [`PublishEvidenceExtra`] enum's per-variant struct list is the
    /// schema, and serde rejects fields it does not name.
    ///
    /// Per-publisher rollback state (runtime-only credentials read
    /// from env / config at publish time) lives in crate-local
    /// `*Target` structs with `#[serde(skip)]` discipline; those
    /// convert into the [`PublishEvidenceExtra`] variant snapshots at
    /// the encode boundary, dropping the secret fields by definition.
    #[serde(default, deserialize_with = "deserialize_extra_compat")]
    pub extra: PublishEvidenceExtra,
}

/// Deserialize the `extra:` field with backwards-compatibility for
/// reports written before the typed [`PublishEvidenceExtra`] enum
/// landed. Those reports carried `extra: {}` (an empty object) where
/// the typed enum's [`Empty`](PublishEvidenceExtra::Empty) variant
/// serializes as `null`. With `#[serde(untagged)]` neither null nor
/// the typed struct variants match `{}`, so a literal `{}` from an
/// older report fails to deserialize. This shim coerces null and `{}`
/// to `Empty`; any other shape goes through the normal untagged
/// dispatch.
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() {
        // The Empty variant is the default for newly constructed evidence;
        // pinning its wire shape ensures back-compat with the prior `{}` /
        // null default and avoids accidental shape drift.
        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() {
        // Untagged enum: null lands on Empty (the unit variant is the
        // only one that accepts a null payload). Pin the wire shape
        // so a future variant addition that breaks this path fails
        // here.
        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() {
        // Wire-format pin: downstream consumers of
        // `dist/run-<id>/report.json` see the same byte shape that
        // shipped pre-typed-enum. A variant addition that drifts the
        // shape (e.g. wraps the homebrew_targets array in an extra
        // object) fails this test.
        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");
        // Defense-in-depth: no credential-shaped keys in the rendered
        // form (matches the per-publisher `*_extra_carries_no_secret_material`
        // tests but pinned at the core wire-format level).
        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}");
    }
}