From fb5a71a94ba74b0931d70c5da90b1f1326f604af Mon Sep 17 00:00:00 2001 From: Kunal Behbudzade <121988281+Kunalbehbud@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:33:17 +0000 Subject: [PATCH] fix(update): fetch attestation bundles from bundle_url (#10557) --- Cargo.lock | 7 ++ Cargo.toml | 1 + crates/goose-cli/Cargo.toml | 3 +- crates/goose-cli/src/commands/update.rs | 103 +++++++++++++++++++++++- 4 files changed, 111 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9f6dccad1..2f8970f3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5123,6 +5123,7 @@ dependencies = [ "sha2 0.11.0", "shlex 2.0.1", "sigstore-verify", + "snap", "strsim", "strum 0.28.0", "tar", @@ -10416,6 +10417,12 @@ dependencies = [ "reqwest 0.13.4", ] +[[package]] +name = "snap" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886" + [[package]] name = "socket2" version = "0.6.4" diff --git a/Cargo.toml b/Cargo.toml index 4cceca121..84e3feac7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,6 +79,7 @@ zip = { version = "8", default-features = false, features = ["deflate"] } serial_test = { version = "4", default-features = false } sha2 = { version = "0.11", default-features = false } shell-words = { version = "1", default-features = false, features = ["std"] } +snap = { version = "1", default-features = false } test-case = { version = "3", default-features = false } url = { version = "2.5.4", default-features = false, features = ["std"] } opentelemetry = { version = "0.32", default-features = false } diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index b24d781ce..b3f6cdcd4 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -64,6 +64,7 @@ clap_complete = { version = "4", default-features = false } comfy-table = { version = "8", default-features = false } sha2 = { workspace = true } sigstore-verify = { version = "0.11", default-features = false, optional = true } +snap = { workspace = true, optional = true } axum.workspace = true axum-server = { workspace = true, optional = true } clap_complete_nushell = { version = "4", default-features = false } @@ -105,7 +106,7 @@ nostr = ["goose/nostr"] otel = ["goose/otel"] system-keyring = ["goose/system-keyring"] tui = [] -update = ["dep:sigstore-verify"] +update = ["dep:sigstore-verify", "dep:snap"] portable-default = ["rustls-tls", "aws-providers", "telemetry", "otel", "tui"] # disables the update command disable-update = [] diff --git a/crates/goose-cli/src/commands/update.rs b/crates/goose-cli/src/commands/update.rs index 18ca97083..291f4e189 100644 --- a/crates/goose-cli/src/commands/update.rs +++ b/crates/goose-cli/src/commands/update.rs @@ -78,7 +78,10 @@ struct AttestationResponse { #[derive(serde::Deserialize)] struct AttestationEntry { - bundle: serde_json::Value, + #[serde(default)] + bundle: Option, + #[serde(default)] + bundle_url: Option, } const GITHUB_ACTIONS_ISSUER: &str = "https://token.actions.githubusercontent.com"; @@ -134,7 +137,53 @@ async fn fetch_attestations(digest: &str, token: Option<&str>) -> Result bundles.push(bundle), + (_, Some(url)) => bundles.push(fetch_bundle(&client, &url).await?), + _ => bail!("Attestation has neither a bundle nor a bundle URL"), + } + } + + Ok(bundles) +} + +// The bundle URL is pre-signed for blob storage, so no API credentials are sent. +async fn fetch_bundle(client: &reqwest::Client, url: &str) -> Result { + let resp = client + .get(url) + .header("User-Agent", "goose-cli") + .send() + .await + .context("Failed to fetch attestation bundle")?; + + if !resp.status().is_success() { + bail!( + "Attestation bundle download returned HTTP {}", + resp.status() + ); + } + + let body = resp + .bytes() + .await + .context("Failed to read attestation bundle")?; + parse_bundle_bytes(&body) +} + +fn parse_bundle_bytes(body: &[u8]) -> Result { + if let Ok(bundle) = serde_json::from_slice(body) { + return Ok(bundle); + } + + // Offloaded bundles are served as snappy-compressed JSON. + let decompressed = snap::raw::Decoder::new() + .decompress_vec(body) + .context("Failed to decompress attestation bundle")?; + serde_json::from_slice(&decompressed).context("Failed to parse attestation bundle") } async fn fetch_attestations_response( @@ -805,6 +854,56 @@ mod tests { )); } + #[test] + fn test_attestation_entry_parses_embedded_bundle() { + let response: AttestationResponse = serde_json::from_str( + r#"{"attestations":[{"repository_id":1,"bundle":{"mediaType":"application/vnd.dev.sigstore.bundle.v0.3+json"}}]}"#, + ) + .unwrap(); + assert!(response.attestations[0].bundle.is_some()); + assert!(response.attestations[0].bundle_url.is_none()); + } + + #[test] + fn test_attestation_entry_parses_offloaded_bundle() { + let response: AttestationResponse = serde_json::from_str( + r#"{"attestations":[{"repository_id":1,"bundle_url":"https://example.com/bundle.json.sn","initiator":"user","bundle":null}]}"#, + ) + .unwrap(); + assert!(response.attestations[0].bundle.is_none()); + assert_eq!( + response.attestations[0].bundle_url.as_deref(), + Some("https://example.com/bundle.json.sn") + ); + } + + #[test] + fn test_parse_bundle_bytes_plain_json() { + let bundle = + parse_bundle_bytes(br#"{"mediaType":"application/vnd.dev.sigstore.bundle.v0.3+json"}"#) + .unwrap(); + assert_eq!( + bundle["mediaType"], + "application/vnd.dev.sigstore.bundle.v0.3+json" + ); + } + + #[test] + fn test_parse_bundle_bytes_snappy_compressed() { + let json = br#"{"mediaType":"application/vnd.dev.sigstore.bundle.v0.3+json"}"#; + let compressed = snap::raw::Encoder::new().compress_vec(json).unwrap(); + let bundle = parse_bundle_bytes(&compressed).unwrap(); + assert_eq!( + bundle["mediaType"], + "application/vnd.dev.sigstore.bundle.v0.3+json" + ); + } + + #[test] + fn test_parse_bundle_bytes_rejects_garbage() { + assert!(parse_bundle_bytes(&[0xff, 0x00, 0x12]).is_err()); + } + // ----------------------------------------------------------------------- // Path validation and extraction hardening tests // -----------------------------------------------------------------------