Skip to main content

cloud/
release_manifest.rs

1//! Yubaba release-manifest fetch + per-triple resolution (R330-F21).
2//!
3//! `.github/workflows/release.yml`'s `yubaba-release-manifest` job publishes
4//! `https://cdn.yah.dev/yubaba/release-manifest.json` after every release tag
5//! (R330-F19). Operators do not need to memorise per-release URLs and sha256s —
6//! `yah cloud machine provision <name>` fetches the manifest once, looks up
7//! the entry matching the machine's architecture, and threads the resolved
8//! URL + sha256 + sig/cert URLs into the cloud-init render.
9//!
10//! Per W203 §1.5 + R330-F19's per-triple entries:
11//!
12//! ```json
13//! {
14//!   "version": "0.9.0",
15//!   "triples": {
16//!     "x86_64-unknown-linux-musl": {
17//!       "url":         "https://cdn.yah.dev/yubaba/0.9.0/x86_64-unknown-linux-musl/yah-yubaba-x86_64-unknown-linux-musl.tar.gz",
18//!       "size":        2345678,
19//!       "sha256":      "abc…",
20//!       // cosign 3.x shape. Manifests cut by the cosign-2.x pipeline carry
21//!       // "sig_url"/"cert_url" here instead — both shapes parse, see
22//!       // [`ManifestTripleEntry`].
23//!       "bundle_url":  "https://cdn.yah.dev/yubaba/.../...tar.gz.sigstore.json"
24//!     },
25//!     "aarch64-unknown-linux-musl": { … }
26//!   }
27//! }
28//! ```
29//!
30//! Identity-regexp is *not* per-release — it identifies the workspace's
31//! GitHub OIDC identity (matches the cosign sign-blob step in release.yml).
32//! See [`DEFAULT_YUBABA_COSIGN_IDENTITY`].
33//!
34//! @yah:ticket(R599-F1, "R2 bundle store: append-only blob/manifest publish + node-side materialize into kamaji LRU cache")
35//! @yah:status(review)
36//! @yah:assignee(agent:bundle-anthropic-ashguard)
37//! @yah:at(2026-07-20T04:20:25Z)
38//! @yah:phase(P1)
39//! @yah:parent(R599)
40//! @yah:depends_on(R599-F2)
41//! @yah:next("R599-F2: wire mesofact-build to EMIT a BundleManifest during a build (add yah-mesofact-bundle default-features=false; assemble app/ + optional bins/ tree, per-file blake3, write manifest.toml). Type already exists -> F2 is 'emit', not 'define'.")
42//! @yah:next("R599-F4: kamaji deploy dispatch calls materialize_bundle/BundleCache into kamaji state dir on Deploy of the mesofact bundle variant (add yah-mesofact-bundle features=store + yah-object-store to kamaji).")
43//! @yah:next("R599-F8: services-tab sync arm calls cloud reconciler::bundle_store::publish_bundle_to_r2 after building a bundle.")
44//! @yah:handoff("LANDED (types + store, 16 tests). New crate oss/yah-base/crates/mesofact-bundle (yah-mesofact-bundle). Default features = serde+blake3+toml TYPES ONLY: BundleManifest, BundleRuntime (self | mesofact/<ver>), BundleHash 64-hex, digest() over canonical length-prefixed fields, blob_key/manifest_key layout. This is what R599-F2's mesofact-build consumes via default-features=false.")
45//! @yah:handoff("Feature `store` adds yah-object-store + publish_bundle (append-only blob dedupe via HEAD + blake3 verify + immutable manifest-by-digest PUT), materialize_bundle (fetch+verify+atomic-rename into cache tree, idempotent, path-traversal guarded), BundleCache (LRU-by-digest, bytes budget, 0=unbounded). Store tests via InMemoryObjectStore+tempdir.")
46//! @yah:handoff("Canonical annotation kept here in release_manifest.rs (one-block-per-ID); the real work is the new crate (its lib.rs has a prose pointer, not a 2nd @yah block). Wired: yah-base workspace member + root + oss/yubaba [patch.crates-io]. cloud deps it (features=store) -> reconciler::bundle_store::publish_bundle_to_r2 (spawn_blocking R2ObjectStore wrapper) + BundlePublishReport re-export. Type home = new minimal crate (user-approved via ask_user): NOT workload-spec (avoids ts-rs/schemars weight at mesofact boundary), NOT mesofact-build (avoids drift); store logic feature-gated so the boundary stays serde-only.")
47//! @yah:verify("cd oss/yah-base && cargo test -p yah-mesofact-bundle (8 pass, types-only)")
48//! @yah:verify("cd oss/yah-base && cargo test -p yah-mesofact-bundle --features store (16 pass)")
49//! @yah:verify("cd oss/yubaba && cargo test -p cloud --lib reconciler::bundle_store (1 pass)")
50
51use anyhow::{anyhow, bail, Context, Result};
52use serde::{Deserialize, Serialize};
53use std::collections::BTreeMap;
54use std::time::Duration;
55
56/// Canonical manifest URL published by `.github/workflows/release.yml`'s
57/// `yubaba-release-manifest` job. Hardcoded so operators don't need to memorise
58/// it; operators *can* still override by passing `--yubaba-manifest-url`.
59pub const DEFAULT_RELEASE_MANIFEST_URL: &str = "https://cdn.yah.dev/yubaba/release-manifest.json";
60
61/// Default cosign keyless OIDC identity regexp the yubaba release pipeline
62/// signs against. Matches the `cosign sign-blob` certificate identity emitted
63/// by GitHub Actions when running under `yah-ai/yah` (R330-F19). Operators
64/// override with `--yubaba-cosign-identity` to point at a fork's OIDC subject.
65pub const DEFAULT_YUBABA_COSIGN_IDENTITY: &str = r"^https://github\.com/yah-ai/yah/";
66
67/// Sigstore Fulcio OIDC issuer for GitHub-Actions-rooted keyless signing —
68/// what `.github/workflows/release.yml`'s `cosign sign-blob` step produces
69/// (R330-F19). The canonical copy; `cloud_init` and the yah CLI's
70/// `yubaba_fetch` both read it from here so the two verify paths can't drift.
71pub const COSIGN_OIDC_ISSUER: &str = "https://token.actions.githubusercontent.com";
72
73/// Prefix that selects key-based verification in a cosign-identity spec.
74/// See [`ReleaseTrust::parse`].
75pub const KEY_TRUST_PREFIX: &str = "key:";
76
77/// How a consumer proves a release artifact is ours (R605-F1).
78///
79/// The two arms are the verify-side mirror of `yah_qed::SigningIdentity`, and
80/// they exist for the same reason: a release cut on QED instead of GitHub
81/// cannot use Fulcio keyless, because the public-good Fulcio only issues certs
82/// for OIDC issuers on its own configured allowlist. W235 §"Off-GHA forfeits
83/// GitHub OIDC keyless cosign" makes the call — key-based cosign, key vaulted
84/// in kamaji — so every consumer that verifies a yubaba/CLI artifact has to be
85/// able to express both.
86///
87/// This is carried through the config plumbing as a plain string (the existing
88/// `--yubaba-cosign-identity` value, the `RenderInput` field, the manifest's
89/// identity regexp) and parsed at the two points that actually shell `cosign`.
90/// Keeping the wire type a string is deliberate: it means flipping a fleet to
91/// key-based trust is a value change, not a schema migration.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub enum ReleaseTrust {
94    /// Fulcio keyless: pin the certificate identity + the OIDC issuer.
95    Keyless {
96        identity_regexp: String,
97        oidc_issuer: String,
98    },
99    /// Key-based: pin the cosign *public* key. `key_ref` is whatever
100    /// `cosign verify-blob --key` accepts — a `.pub` path, an `https://` URL,
101    /// or a KMS URI (`awskms://…`, `hashivault://…`).
102    Key { key_ref: String },
103}
104
105impl ReleaseTrust {
106    /// Parse an operator-supplied cosign-identity spec.
107    ///
108    /// `key:<ref>` selects key-based verification; anything else is a keyless
109    /// certificate-identity regexp against [`COSIGN_OIDC_ISSUER`]. The prefix
110    /// is explicit rather than sniffed, because a KMS URI and a regexp are
111    /// both arbitrary strings and guessing between them would silently pick
112    /// the wrong trust model.
113    pub fn parse(spec: &str) -> Self {
114        match spec.strip_prefix(KEY_TRUST_PREFIX) {
115            Some(key_ref) => Self::Key {
116                key_ref: key_ref.trim().to_string(),
117            },
118            None => Self::Keyless {
119                identity_regexp: spec.to_string(),
120                oidc_issuer: COSIGN_OIDC_ISSUER.to_string(),
121            },
122        }
123    }
124
125    /// The `cosign verify-blob` flags this trust model contributes, in order.
126    /// The caller appends `--bundle <path>`/the blob itself. cosign 3.x's
127    /// bundle format carries the signature (and, for keyless, the Fulcio
128    /// certificate + transparency proof) as ONE file, so there is no
129    /// separate `.cert` sidecar to conditionally fetch any more — the old
130    /// `needs_certificate()` distinction that gated a `.cert` download is
131    /// gone with it.
132    pub fn verify_flags(&self) -> Vec<String> {
133        match self {
134            Self::Keyless {
135                identity_regexp,
136                oidc_issuer,
137            } => vec![
138                "--certificate-identity-regexp".into(),
139                identity_regexp.clone(),
140                "--certificate-oidc-issuer".into(),
141                oidc_issuer.clone(),
142            ],
143            // `--insecure-ignore-tlog`: key-based signing deliberately signs
144            // with no transparency-log upload (W235 — the entire point of
145            // key trust is working with no public-Sigstore-infra dependency,
146            // and an org's release artifacts should not be named in a public
147            // log by default). cosign's verify side has to be told the same
148            // thing, or it hard-fails expecting an inclusion proof that was
149            // never produced. cosign's own naming call this flag "insecure";
150            // for this trust arm it is the correct, intended mode, not a
151            // downgrade — see the R605-F1 signing-key incident this is
152            // paired with (a bare sign-blob call defaults to tlog upload
153            // even in key mode, which is the actual insecure surprise).
154            Self::Key { key_ref } => vec![
155                "--key".into(),
156                key_ref.clone(),
157                "--insecure-ignore-tlog".into(),
158            ],
159        }
160    }
161}
162
163/// One per-triple entry in the manifest.
164///
165/// TWO SIGNATURE SHAPES EXIST IN THE WILD and this type has to read both, so
166/// every signature field is optional. Until R755-T4 measured it (2026-08-26)
167/// this struct required `bundle_url` and the doc comment here claimed the field
168/// order matched what release.yml's `Emit per-triple manifest fragment` step
169/// writes. It did not, and never had: that step emits `sig_url`/`cert_url` and
170/// no `bundle_url` at all. The consequence was total — `fetch_release_manifest`
171/// hard-failed with `missing field bundle_url` against the ONLY manifest ever
172/// published, so `yah cloud rollout plan` could not resolve any release, ever.
173/// Every test in this module used a hand-written fixture carrying `bundle_url`,
174/// which is precisely why fixtures alone never caught it; see
175/// `parses_the_real_published_manifest_bytes` for the regression that would
176/// have, and `scripts/release-manifest-rehearsal.sh`'s header for the same
177/// lesson stated in advance.
178///
179///   * `bundle_url` — cosign 3.x sigstore bundle: signature + certificate +
180///     transparency proof in one file. What the verify path wants.
181///   * `sig_url` / `cert_url` — cosign 2.x detached signature and Fulcio cert.
182///     What release.yml (pinned to cosign v2.6.5) actually emits today, and
183///     what every published manifest through 0.8.20 carries.
184///
185/// Unknown fields (`hash`, `bootstrap_hash`) are ignored by serde, so the
186/// richer fragment shape release.yml writes round-trips without a schema bump.
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct ManifestTripleEntry {
189    pub url: String,
190    pub size: u64,
191    pub sha256: String,
192    /// cosign 3.x sigstore-bundle URL. Absent on manifests cut by the
193    /// cosign-2.x pipeline — use [`ManifestTripleEntry::bundle`] rather than
194    /// reading this directly, so a missing bundle is a named refusal instead of
195    /// an empty URL handed to `curl`.
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub bundle_url: Option<String>,
198    /// cosign 2.x detached signature URL.
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub sig_url: Option<String>,
201    /// cosign 2.x Fulcio certificate URL. Empty string in key-trust mode —
202    /// release.yml writes `""` rather than omitting the key (R605-F1).
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub cert_url: Option<String>,
205}
206
207impl ManifestTripleEntry {
208    /// The sigstore-bundle URL, or a refusal naming why verification cannot
209    /// proceed. Never returns an empty string: an entry that carries only the
210    /// cosign-2.x `sig_url`/`cert_url` pair has no bundle to verify against,
211    /// and silently fetching `""` would surface as an unrelated curl error at
212    /// the wrong layer.
213    pub fn bundle(&self) -> Result<&str> {
214        match self.bundle_url.as_deref().filter(|u| !u.is_empty()) {
215            Some(url) => Ok(url),
216            None if self.sig_url.is_some() => bail!(
217                "release-manifest entry for {} carries a cosign 2.x detached \
218                 signature (sig_url) but no sigstore bundle_url, and the verify \
219                 path needs a bundle. This manifest was cut by the cosign-v2.6.5 \
220                 pipeline in .github/workflows/release.yml; a release cut with \
221                 cosign 3.x publishes bundle_url instead. Pass --yubaba-url + \
222                 --yubaba-sha256 to install without manifest-driven verification.",
223                self.url
224            ),
225            None => bail!(
226                "release-manifest entry for {} declares no signature at all \
227                 (neither bundle_url nor sig_url) — refusing to install \
228                 unverified bytes",
229                self.url
230            ),
231        }
232    }
233}
234
235/// Top-level shape of `release-manifest.json`.
236///
237/// The two epoch fields (R625-F2) are **release-wide, not per-triple** — the
238/// raft protocol and on-disk layout are architecture-independent, so they sit
239/// alongside `version` rather than inside each [`ManifestTripleEntry`]. They are
240/// `Option` because manifests published before this field existed carry neither;
241/// see [`YubabaReleaseManifest::cluster_epochs`] for why absent must never be
242/// read as "compatible".
243#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct YubabaReleaseManifest {
245    pub version: String,
246    /// Wire-compatibility epoch: may a node running this release sit in one raft
247    /// cluster with a node running some other release. Mixed operation is
248    /// permitted iff every node shares this integer (W275 "Cluster compatibility
249    /// epochs"). Sourced from `oss/yubaba/crates/yubaba/cluster-epochs.json`,
250    /// the same file the binary parses at compile time.
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub cluster_protocol: Option<u32>,
253    /// On-disk state epoch: can this release read the previous release's raft
254    /// log/snapshot, and can you roll **back** to it. Tracked separately from
255    /// `cluster_protocol` on purpose — openraft 0.9→0.10 broke both at once,
256    /// which is exactly how one combined flag would have hidden the rollback
257    /// hazard (W275 §5 "Roll-back is symmetric").
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub state_epoch: Option<u32>,
260    pub triples: BTreeMap<String, ManifestTripleEntry>,
261}
262
263impl YubabaReleaseManifest {
264    /// Look up the manifest entry for the given Rust target triple. Returns a
265    /// descriptive error listing the available triples so operators can spot a
266    /// typo or a missing matrix leg at a glance.
267    pub fn entry(&self, triple: &str) -> Result<&ManifestTripleEntry> {
268        self.triples.get(triple).ok_or_else(|| {
269            let available: Vec<_> = self.triples.keys().cloned().collect();
270            anyhow!(
271                "release-manifest has no entry for triple '{triple}' (available: {available:?})"
272            )
273        })
274    }
275}
276
277/// Map a Hetzner server-type code to a Rust target triple. The current matrix
278/// (W203 §1.2) is x86_64 + aarch64 Linux musl. Hetzner's ARM line uses the
279/// `cax` prefix (cax11 / cax21 / …) — every other prefix (cpx*, ccx*, cx*) is
280/// Intel/AMD x86_64. This is operational truth, not heuristic — Hetzner
281/// publishes that mapping in their public pricing page and the catalog crate.
282pub fn server_type_to_triple(server_type: &str) -> Result<&'static str> {
283    if server_type.starts_with("cax") {
284        Ok("aarch64-unknown-linux-musl")
285    } else if server_type.starts_with("cpx")
286        || server_type.starts_with("ccx")
287        || server_type.starts_with("cx")
288    {
289        Ok("x86_64-unknown-linux-musl")
290    } else {
291        bail!(
292            "unknown Hetzner server_type '{server_type}' — cannot infer architecture. \
293             Pass --yubaba-url + --yubaba-sha256 explicitly to skip manifest resolution."
294        )
295    }
296}
297
298/// Fetch the release-manifest from the canonical URL. Caller picks the runtime;
299/// inside `yah cloud machine provision` the existing `tokio::runtime::Runtime`
300/// drives this. Surfaces every failure mode loud (no silent placeholder
301/// fallback) — operators see an actionable message and pass `--yubaba-url`
302/// explicitly to bypass.
303pub async fn fetch_release_manifest(url: &str) -> Result<YubabaReleaseManifest> {
304    let client = reqwest::Client::builder()
305        .timeout(Duration::from_secs(15))
306        .user_agent("yah/cloud release-manifest fetcher")
307        .build()
308        .context("building http client for release-manifest fetch")?;
309    let resp = client
310        .get(url)
311        .send()
312        .await
313        .with_context(|| format!("fetching release-manifest from {url}"))?;
314    if !resp.status().is_success() {
315        bail!(
316            "release-manifest fetch from {url} returned HTTP {} — \
317             pass --yubaba-url explicitly if the manifest is unreachable",
318            resp.status()
319        );
320    }
321    let body = resp
322        .bytes()
323        .await
324        .with_context(|| format!("reading release-manifest body from {url}"))?;
325    let manifest: YubabaReleaseManifest = serde_json::from_slice(&body).with_context(|| {
326        format!(
327            "parsing release-manifest from {url} — pass --yubaba-url explicitly if the manifest is malformed"
328        )
329    })?;
330    if manifest.triples.is_empty() {
331        bail!(
332            "release-manifest from {url} has no triples — \
333             pass --yubaba-url explicitly to bypass manifest resolution"
334        );
335    }
336    Ok(manifest)
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    // ── R605-F1 release trust spec ────────────────────────────────────────
344
345    #[test]
346    fn bare_spec_is_keyless_against_the_default_issuer() {
347        // Every existing config value must keep meaning exactly what it meant
348        // before the key arm existed.
349        assert_eq!(
350            ReleaseTrust::parse(DEFAULT_YUBABA_COSIGN_IDENTITY),
351            ReleaseTrust::Keyless {
352                identity_regexp: DEFAULT_YUBABA_COSIGN_IDENTITY.into(),
353                oidc_issuer: COSIGN_OIDC_ISSUER.into(),
354            }
355        );
356    }
357
358    #[test]
359    fn key_prefix_selects_key_trust_and_trims() {
360        assert_eq!(
361            ReleaseTrust::parse("key: awskms:///alias/yah-release "),
362            ReleaseTrust::Key {
363                key_ref: "awskms:///alias/yah-release".into()
364            }
365        );
366    }
367
368    #[test]
369    fn a_kms_uri_without_the_prefix_stays_keyless() {
370        // Deliberate: sniffing a URI scheme out of a free-form regexp would
371        // silently swap the trust model. The prefix is the only signal.
372        assert!(matches!(
373            ReleaseTrust::parse("awskms:///alias/yah-release"),
374            ReleaseTrust::Keyless { .. }
375        ));
376    }
377
378    #[test]
379    fn verify_flags_track_the_arm() {
380        let keyless = ReleaseTrust::parse(DEFAULT_YUBABA_COSIGN_IDENTITY);
381        assert_eq!(
382            keyless.verify_flags(),
383            vec![
384                "--certificate-identity-regexp".to_string(),
385                DEFAULT_YUBABA_COSIGN_IDENTITY.to_string(),
386                "--certificate-oidc-issuer".to_string(),
387                COSIGN_OIDC_ISSUER.to_string(),
388            ]
389        );
390
391        let keyed = ReleaseTrust::parse("key:cosign.pub");
392        assert_eq!(
393            keyed.verify_flags(),
394            vec![
395                "--key".to_string(),
396                "cosign.pub".to_string(),
397                "--insecure-ignore-tlog".to_string(),
398            ]
399        );
400    }
401
402    #[test]
403    fn server_type_to_triple_maps_arm_prefix() {
404        assert_eq!(
405            server_type_to_triple("cax11").unwrap(),
406            "aarch64-unknown-linux-musl"
407        );
408        assert_eq!(
409            server_type_to_triple("cax21").unwrap(),
410            "aarch64-unknown-linux-musl"
411        );
412        assert_eq!(
413            server_type_to_triple("cax41").unwrap(),
414            "aarch64-unknown-linux-musl"
415        );
416    }
417
418    #[test]
419    fn server_type_to_triple_maps_x86_prefixes() {
420        assert_eq!(
421            server_type_to_triple("cpx22").unwrap(),
422            "x86_64-unknown-linux-musl"
423        );
424        assert_eq!(
425            server_type_to_triple("ccx13").unwrap(),
426            "x86_64-unknown-linux-musl"
427        );
428        assert_eq!(
429            server_type_to_triple("cx32").unwrap(),
430            "x86_64-unknown-linux-musl"
431        );
432    }
433
434    #[test]
435    fn server_type_to_triple_rejects_unknown_prefix() {
436        let err = server_type_to_triple("xxx99").unwrap_err().to_string();
437        assert!(err.contains("unknown Hetzner server_type"), "msg: {err}");
438        assert!(
439            err.contains("--yubaba-url"),
440            "msg points to override: {err}"
441        );
442    }
443
444    #[test]
445    fn manifest_round_trips_through_json() {
446        let raw = r#"{
447            "version": "0.9.0",
448            "triples": {
449                "x86_64-unknown-linux-musl": {
450                    "url": "https://cdn.yah.dev/yubaba/0.9.0/x86_64-unknown-linux-musl/yah-yubaba-x86_64-unknown-linux-musl.tar.gz",
451                    "size": 2345678,
452                    "sha256": "abc123",
453                    "bundle_url": "https://cdn.yah.dev/yubaba/0.9.0/x86_64-unknown-linux-musl/yah-yubaba-x86_64-unknown-linux-musl.tar.gz.sigstore.json"
454                }
455            }
456        }"#;
457        let parsed: YubabaReleaseManifest = serde_json::from_str(raw).unwrap();
458        assert_eq!(parsed.version, "0.9.0");
459        // This manifest predates R625-F2 — the epochs are absent, not zero.
460        assert_eq!(parsed.cluster_protocol, None);
461        assert_eq!(parsed.state_epoch, None);
462        let entry = parsed.entry("x86_64-unknown-linux-musl").unwrap();
463        assert_eq!(entry.size, 2345678);
464        assert_eq!(entry.sha256, "abc123");
465        assert!(entry.bundle().unwrap().ends_with(".tar.gz.sigstore.json"));
466    }
467
468    /// The regression the hand-written fixtures above could never catch.
469    ///
470    /// These are the VERBATIM bytes served at
471    /// `https://cdn.yah.dev/yubaba/release-manifest.json` (fetched 2026-08-26,
472    /// version 0.8.20 — the only yubaba manifest ever published). Every other
473    /// fixture in this module was written against the type instead of against
474    /// the pipeline, so all of them carried `bundle_url` and all of them passed
475    /// while `fetch_release_manifest` was failing in production with
476    /// `missing field bundle_url` — reproduced live via
477    /// `yah cloud rollout plan --to 0.8.20 --node us-west-003`.
478    ///
479    /// If a future schema change breaks real published manifests again, this is
480    /// the test that goes red. Do not "fix" it by editing the JSON to match the
481    /// type; re-fetch the live manifest and make the type read it.
482    #[test]
483    fn parses_the_real_published_manifest_bytes() {
484        let raw = r#"{
485  "version": "0.8.20",
486  "triples": {
487    "aarch64-unknown-linux-musl": {
488      "url": "https://cdn.yah.dev/yubaba/0.8.20/aarch64-unknown-linux-musl/yubaba-aarch64-unknown-linux-musl.tar.gz",
489      "size": 9780352,
490      "sha256": "be8bb1602cd8827cb8aa5c56639a84c03d47508405b3baee5aa004a7d0787797",
491      "sig_url": "https://cdn.yah.dev/yubaba/0.8.20/aarch64-unknown-linux-musl/yubaba-aarch64-unknown-linux-musl.tar.gz.sig",
492      "cert_url": "https://cdn.yah.dev/yubaba/0.8.20/aarch64-unknown-linux-musl/yubaba-aarch64-unknown-linux-musl.tar.gz.cert"
493    },
494    "x86_64-unknown-linux-musl": {
495      "url": "https://cdn.yah.dev/yubaba/0.8.20/x86_64-unknown-linux-musl/yubaba-x86_64-unknown-linux-musl.tar.gz",
496      "size": 10067454,
497      "sha256": "ce1978cf9d7362621290265e87e0bdb55de4b2410b6c8c6931c3cf806a462767",
498      "sig_url": "https://cdn.yah.dev/yubaba/0.8.20/x86_64-unknown-linux-musl/yubaba-x86_64-unknown-linux-musl.tar.gz.sig",
499      "cert_url": "https://cdn.yah.dev/yubaba/0.8.20/x86_64-unknown-linux-musl/yubaba-x86_64-unknown-linux-musl.tar.gz.cert"
500    }
501  }
502}"#;
503        let parsed: YubabaReleaseManifest =
504            serde_json::from_str(raw).expect("the live published manifest must parse");
505        assert_eq!(parsed.version, "0.8.20");
506        assert_eq!(parsed.triples.len(), 2);
507        // Predates R625-F2, so it declares neither epoch. Absent is "unproven",
508        // never "compatible" — see ClusterEpochs.
509        assert_eq!(parsed.cluster_protocol, None);
510        assert_eq!(parsed.state_epoch, None);
511
512        let entry = parsed.entry("x86_64-unknown-linux-musl").unwrap();
513        assert_eq!(entry.size, 10_067_454);
514        assert!(entry.sig_url.as_deref().unwrap().ends_with(".tar.gz.sig"));
515        assert!(entry.bundle_url.is_none());
516
517        // No bundle to verify against — and the refusal must SAY that, rather
518        // than let an empty URL reach the fetcher.
519        let err = entry.bundle().unwrap_err().to_string();
520        assert!(err.contains("sig_url"), "names the shape it found: {err}");
521        assert!(err.contains("bundle_url"), "names what it wanted: {err}");
522    }
523
524    /// The richer per-triple shape release.yml's fragment step actually writes:
525    /// extra `hash` / `bootstrap_hash` keys, and `cert_url: ""` in key-trust
526    /// mode (R605-F1). Unknown keys are ignored; the empty cert is preserved
527    /// rather than mistaken for a missing field.
528    #[test]
529    fn parses_the_release_yml_fragment_shape() {
530        let raw = r#"{
531            "version": "0.8.29",
532            "cluster_protocol": 5,
533            "state_epoch": 2,
534            "triples": {
535                "x86_64-unknown-linux-musl": {
536                    "url": "https://cdn.yah.dev/yubaba/0.8.29/x86_64-unknown-linux-musl/yubaba-x86_64-unknown-linux-musl.tar.gz",
537                    "size": 10000000,
538                    "hash": "blake3:aa",
539                    "bootstrap_hash": "sha256:bb",
540                    "sha256": "bb",
541                    "sig_url": "https://cdn.yah.dev/yubaba/0.8.29/x86_64-unknown-linux-musl/yubaba-x86_64-unknown-linux-musl.tar.gz.sig",
542                    "cert_url": ""
543                }
544            }
545        }"#;
546        let parsed: YubabaReleaseManifest = serde_json::from_str(raw).unwrap();
547        assert_eq!(parsed.cluster_protocol, Some(5));
548        let entry = parsed.entry("x86_64-unknown-linux-musl").unwrap();
549        assert_eq!(entry.sha256, "bb");
550        assert_eq!(entry.cert_url.as_deref(), Some(""));
551        assert!(entry.bundle().is_err());
552    }
553
554    #[test]
555    fn manifest_entry_missing_triple_lists_available() {
556        let raw = r#"{"version":"0.9.0","triples":{"x86_64-unknown-linux-musl":{"url":"u","size":1,"sha256":"s","bundle_url":"u.sigstore.json"}}}"#;
557        let parsed: YubabaReleaseManifest = serde_json::from_str(raw).unwrap();
558        let err = parsed
559            .entry("aarch64-unknown-linux-musl")
560            .unwrap_err()
561            .to_string();
562        assert!(err.contains("aarch64-unknown-linux-musl"));
563        assert!(
564            err.contains("x86_64-unknown-linux-musl"),
565            "lists available: {err}"
566        );
567    }
568
569    #[test]
570    fn manifest_carries_the_two_cluster_epochs() {
571        // R625-F2. The shape release.yml now publishes: two release-wide
572        // integers alongside `version`, NOT per-triple (raft protocol and
573        // on-disk layout are architecture-independent).
574        let raw = r#"{
575            "version": "0.8.22",
576            "cluster_protocol": 2,
577            "state_epoch": 2,
578            "triples": {
579                "x86_64-unknown-linux-musl": {
580                    "url": "u", "size": 1, "sha256": "s", "bundle_url": "b"
581                }
582            }
583        }"#;
584        let parsed: YubabaReleaseManifest = serde_json::from_str(raw).unwrap();
585        assert_eq!(parsed.cluster_protocol, Some(2));
586        assert_eq!(parsed.state_epoch, Some(2));
587
588        // Round-trip keeps them, and a manifest that declares nothing emits
589        // nothing (rather than an implicit 0, which would read as "declared").
590        let back: YubabaReleaseManifest =
591            serde_json::from_str(&serde_json::to_string(&parsed).unwrap()).unwrap();
592        assert_eq!(back.cluster_protocol, Some(2));
593        assert_eq!(back.state_epoch, Some(2));
594
595        let undeclared = YubabaReleaseManifest {
596            version: "0.8.20".into(),
597            cluster_protocol: None,
598            state_epoch: None,
599            triples: BTreeMap::new(),
600        };
601        let json = serde_json::to_string(&undeclared).unwrap();
602        assert!(!json.contains("cluster_protocol"), "{json}");
603        assert!(!json.contains("state_epoch"), "{json}");
604    }
605
606    #[test]
607    fn default_identity_regexp_matches_yah_ai_yah() {
608        // Sanity: the constant matches what release.yml's cosign sign-blob
609        // step emits as the keyless certificate identity. Bumping the org
610        // requires updating both this constant AND release.yml in lockstep.
611        assert!(DEFAULT_YUBABA_COSIGN_IDENTITY.contains("yah-ai/yah"));
612    }
613}