Skip to main content

microvms_edges/provision/
release.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The daemon release over HTTPS, and its attestation checked in-process (BIND-18, #284).
3//!
4//! [`GitHubRelease`] is the app's [`ReleaseSource`] over reqwest, [`SigstoreVerifier`] its
5//! [`AttestationVerifier`] over `sigstore-verify`, [`PolicyFetch`] the [`Fetch`] that runs
6//! [`fetch_release`] over a pair of them and writes the proven bytes where the cache asked, and
7//! [`HttpsFetch`] that over these two. Nothing here
8//! spawns a tool, so a machine with no `gh`, or a `gh` that isn't logged in, still gets an
9//! attestation check.
10//!
11//! # Where a bundle comes from
12//!
13//! The release asset [`BUNDLE_ASSET`] first: the release workflow uploads the same bundle it
14//! publishes to the attestations API (measured 2026-09-25: the two are identical JSON), and a
15//! release download isn't counted against the API's rate limit, which is sixty requests an hour
16//! for an unauthenticated address. The API by digest is the fallback, with `GITHUB_TOKEN` when
17//! it's set.
18//!
19//! # Which answers mean "there is no bundle"
20//!
21//! The policy refuses the bytes on [`Bundles::Absent`] and falls back to `SHA256SUMS` on
22//! [`Bundles::Unreachable`], so the line between them is where provenance can be stripped.
23//! Only a 404 is an answer; every other failure (a transport error, a 403 or 429 rate limit, a
24//! 5xx, a body that doesn't parse) is a failure to get one. The lookup is absent when the
25//! bundle asset is a 404 and the API has no bundle to give, or when the API itself says it
26//! has no attestation for the digest (a 404 or an empty list). It's unreachable only when
27//! neither answered.
28//!
29//! A bundle asset that downloads is the bundle, whatever it holds: one that doesn't parse is
30//! refused by the verifier rather than skipped for the API, because otherwise uploading junk
31//! in its place would do what deleting it no longer can. Measured 2026-09-26: v0.5.0 on,
32//! every release carries both the bundle asset and `SHA256SUMS`, and before that neither, so
33//! none of these refusals turns away a release the checksum would have passed.
34//!
35//! # A release on disk
36//!
37//! With [`RELEASE_DIR_VARIABLE`] set, [`HttpsFetch`] reads release `<tag>`'s files from
38//! `$MICROVM_RELEASE_DIR/<tag>/`, where `gh release download <tag> --dir` puts them, and not
39//! from GitHub. It's for a release nobody can download anonymously yet: the release workflow
40//! drafts each release, and a draft is visible only to an account that can push, so the live
41//! run that gates publishing reads the draft's assets from disk (`live-conformance.yml`). It
42//! serves a machine that can't reach GitHub as well.
43//!
44//! The proof doesn't change. The bundle must be the `agentd.sigstore.json` beside the asset
45//! and verify against [`Signer::release`] for the requested tag, and every failure to read it
46//! is [`Bundles::Absent`]: a directory always answers, so this source never reaches the
47//! `SHA256SUMS` fallback, and the one proof it can give is the attestation. A set variable is
48//! the source: a directory without the requested release is an error, never a fall-through
49//! to GitHub, for the reason a caller-supplied binary is.
50//!
51//! # Why every request gets a runtime of its own
52//!
53//! [`Fetch`] is synchronous, and the CLI calls it from inside its own tokio runtime, on a worker
54//! thread. Starting a runtime there, or dropping one, panics. So each request runs on a scoped
55//! thread with a current-thread runtime and a client that live only for that request, which
56//! works the same from a runtime worker, a `spawn_blocking` thread, or a plain `main`.
57
58use std::fmt;
59use std::future::Future;
60use std::io::Read as _;
61use std::path::{Path, PathBuf};
62use std::time::Duration;
63
64use microvms_app::provision::{
65    ASSET, AttestationVerifier, Bundles, CHECKSUMS, RELEASE_REPO, ReleaseSource, Signer,
66    fetch_release,
67};
68
69use super::{Fetch, Verification};
70
71/// The release asset holding the Sigstore bundle for [`ASSET`], from v0.5.0 on.
72pub const BUNDLE_ASSET: &str = "agentd.sigstore.json";
73
74/// The variable whose token raises the attestations API's rate limit.
75pub const TOKEN_VARIABLE: &str = "GITHUB_TOKEN";
76
77/// The variable naming a directory of downloaded releases, read in place of GitHub: release
78/// `<tag>`'s files are in `<dir>/<tag>/`. See the module docs.
79pub const RELEASE_DIR_VARIABLE: &str = "MICROVM_RELEASE_DIR";
80
81/// The largest daemon asset accepted. Releases so far are about 2 MiB; a ceiling keeps a
82/// hostile or broken server from filling memory.
83const ASSET_LIMIT: usize = 64 << 20;
84
85/// The largest `SHA256SUMS` or bundle accepted. Both are a few KiB.
86const SMALL_LIMIT: usize = 1 << 20;
87
88/// The largest attestations API response accepted: one bundle per attestation.
89const API_LIMIT: usize = 8 << 20;
90
91/// The release's files over HTTPS: the public download URLs for assets, and GitHub's
92/// attestations API for a bundle the release doesn't carry.
93#[derive(Clone, Default)]
94pub struct GitHubRelease {
95    token: Option<String>,
96}
97
98impl GitHubRelease {
99    /// A source that sends `token` to the attestations API. `None`, or an empty token, sends
100    /// none: the API answers a public repository without one, at the lower rate limit.
101    pub fn new(token: Option<String>) -> Self {
102        Self {
103            token: token.filter(|token| !token.is_empty()),
104        }
105    }
106
107    /// A source with [`TOKEN_VARIABLE`]'s value from `env`, when it's set.
108    pub fn from_env(env: &dyn Fn(&str) -> Option<String>) -> Self {
109        Self::new(env(TOKEN_VARIABLE))
110    }
111
112    /// [`ReleaseSource::attestations`] over any `get`, so the tests can answer each request
113    /// with the status they need. See the module docs for which answers are which.
114    fn lookup(&self, tag: &str, sha256: &str, get: &Get<'_>) -> Bundles {
115        let asset_reason = match get(&asset_url(tag, BUNDLE_ASSET), None, SMALL_LIMIT) {
116            Ok(body) => return Bundles::Published(vec![String::from_utf8_lossy(&body).into()]),
117            Err(failure) => failure,
118        };
119        let (api_absent, api_reason) = match self.api_bundles(sha256, get) {
120            Ok(bundles) if !bundles.is_empty() => return Bundles::Published(bundles),
121            Ok(_) => (true, "no attestation for the digest".to_string()),
122            Err(failure) => (failure.not_found(), failure.message),
123        };
124        let reason = format!(
125            "the {BUNDLE_ASSET} asset: {}; the attestations API: {api_reason}",
126            asset_reason.message
127        );
128        if asset_reason.not_found() || api_absent {
129            Bundles::Absent(reason)
130        } else {
131            Bundles::Unreachable(reason)
132        }
133    }
134
135    /// The bundles the attestations API lists inline for one digest. A list whose entries
136    /// carry no inline bundle is a failure rather than an empty answer: the attestations
137    /// exist, and this client can't read them.
138    fn api_bundles(&self, sha256: &str, get: &Get<'_>) -> Result<Vec<String>, HttpFailure> {
139        let url = attestations_url(sha256);
140        let body = get(&url, self.token.as_deref(), API_LIMIT).map_err(|failure| {
141            if self.token.is_none() && matches!(failure.status, Some(403 | 429)) {
142                HttpFailure {
143                    message: format!(
144                        "{} (the unauthenticated rate limit; set {TOKEN_VARIABLE} to raise it)",
145                        failure.message
146                    ),
147                    ..failure
148                }
149            } else {
150                failure
151            }
152        })?;
153        parse_attestations(&body).map_err(|message| HttpFailure {
154            status: None,
155            message,
156        })
157    }
158}
159
160/// One `GET`: the URL, the token for the API, and the body limit.
161type Get<'a> = dyn Fn(&str, Option<&str>, usize) -> Result<Vec<u8>, HttpFailure> + 'a;
162
163/// Why a `GET` returned no body.
164#[derive(Clone, Debug, PartialEq, Eq)]
165struct HttpFailure {
166    /// The response status, when a response arrived.
167    status: Option<u16>,
168    message: String,
169}
170
171impl HttpFailure {
172    /// The server answered that the resource doesn't exist: the one failure that is an answer.
173    fn not_found(&self) -> bool {
174        self.status == Some(404)
175    }
176}
177
178/// The shipped `GET`: [`get`] on a runtime of its own.
179fn https_get(url: &str, token: Option<&str>, limit: usize) -> Result<Vec<u8>, HttpFailure> {
180    run(get(url, token, limit))
181}
182
183/// Written by hand so the token never reaches a log.
184impl fmt::Debug for GitHubRelease {
185    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
186        f.debug_struct("GitHubRelease")
187            .field("token", &self.token.as_ref().map(|_| "<redacted>"))
188            .finish()
189    }
190}
191
192impl ReleaseSource for GitHubRelease {
193    fn asset(&self, tag: &str, name: &str) -> Result<Vec<u8>, String> {
194        https_get(&asset_url(tag, name), None, ASSET_LIMIT).map_err(|failure| failure.message)
195    }
196
197    fn checksums(&self, tag: &str) -> Result<String, String> {
198        let body = https_get(&asset_url(tag, CHECKSUMS), None, SMALL_LIMIT)
199            .map_err(|failure| failure.message)?;
200        String::from_utf8(body).map_err(|_| format!("{CHECKSUMS} is not UTF-8"))
201    }
202
203    fn attestations(&self, tag: &str, sha256: &str) -> Bundles {
204        self.lookup(tag, sha256, &https_get)
205    }
206}
207
208/// The public download URL for `asset` on release `tag`. It redirects to GitHub's CDN and
209/// needs no token for a public repository.
210pub fn asset_url(tag: &str, asset: &str) -> String {
211    format!("https://github.com/{RELEASE_REPO}/releases/download/{tag}/{asset}")
212}
213
214/// The attestations API's URL for the artifact whose SHA-256 is `sha256`.
215pub fn attestations_url(sha256: &str) -> String {
216    format!("https://api.github.com/repos/{RELEASE_REPO}/attestations/sha256:{sha256}")
217}
218
219/// Releases already on disk, release `<tag>`'s files in `<root>/<tag>/`: the source behind
220/// [`RELEASE_DIR_VARIABLE`]. See the module docs for why it never answers
221/// [`Bundles::Unreachable`].
222#[derive(Clone, Debug)]
223struct DirectoryRelease {
224    root: PathBuf,
225}
226
227impl DirectoryRelease {
228    /// Release `tag`'s file `name`, with the variable that pointed here named in any error.
229    fn read(&self, tag: &str, name: &str, limit: usize) -> Result<Vec<u8>, String> {
230        read_limited(&self.root.join(tag).join(name), limit)
231            .map_err(|reason| format!("${RELEASE_DIR_VARIABLE}: {reason}"))
232    }
233}
234
235impl ReleaseSource for DirectoryRelease {
236    fn asset(&self, tag: &str, name: &str) -> Result<Vec<u8>, String> {
237        self.read(tag, name, ASSET_LIMIT)
238    }
239
240    fn checksums(&self, tag: &str) -> Result<String, String> {
241        let body = self.read(tag, CHECKSUMS, SMALL_LIMIT)?;
242        String::from_utf8(body).map_err(|_| format!("{CHECKSUMS} is not UTF-8"))
243    }
244
245    fn attestations(&self, tag: &str, _: &str) -> Bundles {
246        // Absent on every failure, a missing file and an unreadable one alike. Unreachable
247        // would send the policy to `SHA256SUMS`, and a directory holding an asset with a
248        // matching checksum but no bundle is exactly a replaced asset.
249        match self.read(tag, BUNDLE_ASSET, SMALL_LIMIT) {
250            Ok(body) => Bundles::Published(vec![String::from_utf8_lossy(&body).into()]),
251            Err(reason) => Bundles::Absent(reason),
252        }
253    }
254}
255
256/// The bytes of the file at `path`, refused when there are more than `limit` of them: the same
257/// ceiling a download has.
258fn read_limited(path: &Path, limit: usize) -> Result<Vec<u8>, String> {
259    let file = std::fs::File::open(path)
260        .map_err(|error| format!("could not open {}: {error}", path.display()))?;
261    let mut bytes = Vec::new();
262    // One byte past the limit is enough to tell a file over it from one exactly at it.
263    file.take(limit as u64 + 1)
264        .read_to_end(&mut bytes)
265        .map_err(|error| format!("could not read {}: {error}", path.display()))?;
266    if bytes.len() > limit {
267        return Err(format!("{} is over {limit} bytes", path.display()));
268    }
269    Ok(bytes)
270}
271
272/// The bundles in an attestations API response, as JSON text for the verifier.
273fn parse_attestations(body: &[u8]) -> Result<Vec<String>, String> {
274    let response: serde_json::Value = serde_json::from_slice(body)
275        .map_err(|error| format!("the response is not JSON: {error}"))?;
276    let listed = response["attestations"]
277        .as_array()
278        .ok_or("the response has no `attestations` list")?;
279    let bundles: Vec<String> = listed
280        .iter()
281        .filter(|attestation| attestation["bundle"].is_object())
282        .map(|attestation| attestation["bundle"].to_string())
283        .collect();
284    if bundles.is_empty() && !listed.is_empty() {
285        return Err("the response lists attestations with no inline bundle".to_string());
286    }
287    Ok(bundles)
288}
289
290/// Runs one request to completion on a scoped thread with a runtime of its own. See the module
291/// docs for why.
292fn run<T: Send>(
293    request: impl Future<Output = Result<T, HttpFailure>> + Send,
294) -> Result<T, HttpFailure> {
295    let failure = |message: String| HttpFailure {
296        status: None,
297        message,
298    };
299    std::thread::scope(|scope| {
300        scope
301            .spawn(|| {
302                tokio::runtime::Builder::new_current_thread()
303                    .enable_all()
304                    .build()
305                    .map_err(|error| {
306                        failure(format!(
307                            "could not start a runtime for the download: {error}"
308                        ))
309                    })?
310                    .block_on(request)
311            })
312            .join()
313            .unwrap_or_else(|_| Err(failure("the download thread panicked".to_string())))
314    })
315}
316
317/// `GET url`, failing on any status but success and on a body over `limit` bytes.
318///
319/// HTTPS only, redirects included, as `curl --proto =https` enforced before this. `token`
320/// goes only to the API, the one host whose rate limit it raises: a release download needs
321/// none, and it would otherwise follow the redirect to the CDN.
322async fn get(url: &str, token: Option<&str>, limit: usize) -> Result<Vec<u8>, HttpFailure> {
323    let failure = |message: String| HttpFailure {
324        status: None,
325        message,
326    };
327    let client = reqwest::Client::builder()
328        .https_only(true)
329        // The API refuses a request without a User-Agent.
330        .user_agent(concat!("crates/microvms-edges/", env!("CARGO_PKG_VERSION")))
331        .connect_timeout(Duration::from_secs(30))
332        // The ceiling curl had: a stalled transfer is an error rather than a hang.
333        .timeout(Duration::from_secs(300))
334        .build()
335        .map_err(|error| failure(format!("could not build an HTTP client: {}", chain(&error))))?;
336    let mut request = client.get(url);
337    if url.starts_with("https://api.github.com/") {
338        request = request
339            .header("Accept", "application/vnd.github+json")
340            .header("X-GitHub-Api-Version", "2022-11-28");
341        if let Some(token) = token {
342            request = request.bearer_auth(token);
343        }
344    }
345    let mut response = request
346        .send()
347        .await
348        .map_err(|error| failure(format!("GET {url}: {}", chain(&error))))?;
349    let mut body = Body::start(url, response.status(), response.content_length(), limit)?;
350    while let Some(chunk) = response
351        .chunk()
352        .await
353        .map_err(|error| failure(format!("GET {url}: {}", chain(&error))))?
354    {
355        body.push(&chunk)?;
356    }
357    Ok(body.bytes)
358}
359
360/// A response body read under a byte limit. The status and size rules live here rather than in
361/// [`get`] so they have an offline test; `get` only feeds in what the network returns.
362struct Body<'a> {
363    url: &'a str,
364    limit: usize,
365    bytes: Vec<u8>,
366}
367
368impl<'a> Body<'a> {
369    /// An empty body for a response with `status` that declared `length` bytes, or why it's
370    /// refused before a byte is read: a status other than success, or a declared length over
371    /// `limit`.
372    fn start(
373        url: &'a str,
374        status: reqwest::StatusCode,
375        length: Option<u64>,
376        limit: usize,
377    ) -> Result<Self, HttpFailure> {
378        if !status.is_success() {
379            return Err(HttpFailure {
380                status: Some(status.as_u16()),
381                message: format!("GET {url}: HTTP {}", status.as_u16()),
382            });
383        }
384        let body = Self {
385            url,
386            limit,
387            bytes: Vec::new(),
388        };
389        if length.is_some_and(|length| length > limit as u64) {
390            return Err(body.over_limit());
391        }
392        Ok(body)
393    }
394
395    /// Appends `chunk`, refusing one that takes the body over the limit. A server can send
396    /// more than it declared, or declare nothing.
397    fn push(&mut self, chunk: &[u8]) -> Result<(), HttpFailure> {
398        if self.bytes.len() + chunk.len() > self.limit {
399            return Err(self.over_limit());
400        }
401        self.bytes.extend_from_slice(chunk);
402        Ok(())
403    }
404
405    fn over_limit(&self) -> HttpFailure {
406        HttpFailure {
407            status: None,
408            message: format!("GET {}: the body is over {} bytes", self.url, self.limit),
409        }
410    }
411}
412
413/// An error and its causes on one line. reqwest's own message stops at "error sending request",
414/// and the cause (a DNS failure, a refused connection, a timeout) is what a reader acts on.
415fn chain(error: &dyn std::error::Error) -> String {
416    let mut text = error.to_string();
417    let mut source = error.source();
418    while let Some(cause) = source {
419        text.push_str(": ");
420        text.push_str(&cause.to_string());
421        source = cause.source();
422    }
423    text
424}
425
426/// The Sigstore check, in-process, against the public-good trusted root `sigstore-trust-root`
427/// embeds.
428///
429/// It checks everything `gh attestation verify` does: the certificate chain to Fulcio, the
430/// certificate transparency SCT, the Rekor inclusion proof, checkpoint and SET, the
431/// certificate's validity at the logged time, the DSSE signature, and the in-toto subject
432/// against the artifact's digest. Then the signer: the certificate identity and issuer through
433/// the policy, and the statement's predicate type after it.
434///
435/// # The root is as old as this build
436///
437/// The root is the one `sigstore-trust-root` embedded when this crate's pin was last bumped,
438/// and nothing refreshes it at run time (`tuf` is off; see `Cargo.toml`). A bundle logged to a
439/// Rekor shard or signed under a key newer than the root is refused like a tampered one. Each
440/// client fetches its own version's daemon, so the release workflow is where that would
441/// surface: it verifies the bundle it just produced with this type before it publishes
442/// anything (`tests/release_bundle.rs`), and a refusal there means bumping the pin, not
443/// turning a check off.
444pub struct SigstoreVerifier {
445    verifier: sigstore_verify::Verifier,
446}
447
448impl SigstoreVerifier {
449    /// A verifier over the embedded public-good trusted root. Fails only if that root doesn't
450    /// parse, which a test below would have caught first.
451    pub fn public_good() -> Result<Self, String> {
452        let root = sigstore_trust_root::TrustedRoot::from_json(
453            sigstore_trust_root::SIGSTORE_PRODUCTION_TRUSTED_ROOT,
454        )
455        .map_err(|error| format!("the embedded Sigstore trusted root: {error}"))?;
456        let verifier = sigstore_verify::Verifier::new(&root)
457            .map_err(|error| format!("the embedded Sigstore trusted root: {error}"))?;
458        Ok(Self { verifier })
459    }
460}
461
462impl fmt::Debug for SigstoreVerifier {
463    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
464        f.write_str("SigstoreVerifier(public-good)")
465    }
466}
467
468impl AttestationVerifier for SigstoreVerifier {
469    fn verify(&self, bundle: &str, artifact: &[u8], signer: &Signer) -> Result<(), String> {
470        let bundle = sigstore_types::Bundle::from_json(bundle)
471            .map_err(|error| format!("the bundle doesn't parse: {error}"))?;
472        // Never `skip_tlog_unsafe`, `skip_sct` or `skip_certificate_chain`: the log evidence is
473        // what proves the short-lived certificate signed inside its ten-minute window.
474        let policy = sigstore_verify::VerificationPolicy::new(&signer.identity, &signer.issuer);
475        self.verifier
476            .verify(artifact, &bundle, &policy)
477            .map_err(|error| error.to_string())?;
478        // `sigstore-verify` binds the subject but not the predicate, and `gh` checks it: a
479        // bundle the release workflow signed over some other statement isn't provenance.
480        let sigstore_types::SignatureContent::DsseEnvelope(envelope) = &bundle.content else {
481            return Err("the bundle signs a message, not an in-toto attestation".to_string());
482        };
483        let statement: sigstore_types::Statement =
484            serde_json::from_slice(&envelope.decode_payload())
485                .map_err(|error| format!("the attested statement doesn't parse: {error}"))?;
486        if statement.predicate_type != signer.predicate_type {
487            return Err(format!(
488                "predicate type mismatch: expected {}, got {}",
489                signer.predicate_type, statement.predicate_type
490            ));
491        }
492        Ok(())
493    }
494}
495
496/// A [`Fetch`] that runs [`fetch_release`] over any source and verifier, writing the proven
497/// bytes to the destination the cache gave it, and nothing when the policy refuses.
498/// [`HttpsFetch`] is this over GitHub and the public-good root; a test answers with a release
499/// of its own.
500#[derive(Clone, Debug, Default)]
501pub struct PolicyFetch<S, V> {
502    pub source: S,
503    pub verifier: V,
504}
505
506impl<S: ReleaseSource, V: AttestationVerifier> Fetch for PolicyFetch<S, V> {
507    fn fetch(
508        &self,
509        tag: &str,
510        dest: &Path,
511        progress: &mut dyn FnMut(&str),
512    ) -> Result<Verification, String> {
513        let fetched = fetch_release(&self.source, &self.verifier, tag, progress)?;
514        std::fs::write(dest, &fetched.bytes)
515            .map_err(|error| format!("could not write {ASSET} to {}: {error}", dest.display()))?;
516        Ok(fetched.verification)
517    }
518}
519
520/// The shipped [`Fetch`]: [`PolicyFetch`] over [`GitHubRelease`] and [`SigstoreVerifier`], or
521/// over the release directory [`RELEASE_DIR_VARIABLE`] names when it's set.
522#[derive(Clone, Debug, Default)]
523pub struct HttpsFetch {
524    release: GitHubRelease,
525    directory: Option<DirectoryRelease>,
526}
527
528impl HttpsFetch {
529    /// A fetch that sends [`TOKEN_VARIABLE`]'s value from `env` to the attestations API, or,
530    /// when `env` sets [`RELEASE_DIR_VARIABLE`], one that reads the release from that
531    /// directory. An empty value is unset.
532    pub fn from_env(env: &dyn Fn(&str) -> Option<String>) -> Self {
533        Self {
534            release: GitHubRelease::from_env(env),
535            directory: env(RELEASE_DIR_VARIABLE)
536                .filter(|root| !root.is_empty())
537                .map(|root| DirectoryRelease {
538                    root: PathBuf::from(root),
539                }),
540        }
541    }
542}
543
544impl Fetch for HttpsFetch {
545    fn fetch(
546        &self,
547        tag: &str,
548        dest: &Path,
549        progress: &mut dyn FnMut(&str),
550    ) -> Result<Verification, String> {
551        let verifier = SigstoreVerifier::public_good()?;
552        match &self.directory {
553            Some(directory) => {
554                progress(&format!(
555                    "reading {ASSET} {tag} from {} (${RELEASE_DIR_VARIABLE})",
556                    directory.root.join(tag).display()
557                ));
558                PolicyFetch {
559                    source: directory.clone(),
560                    verifier,
561                }
562                .fetch(tag, dest, progress)
563            }
564            None => PolicyFetch {
565                source: self.release.clone(),
566                verifier,
567            }
568            .fetch(tag, dest, progress),
569        }
570    }
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576    use microvms_app::provision::{ReleaseSource, sha256_hex};
577
578    // A real release, committed: the v0.7.0 daemon (the smallest attested `agentd` signed
579    // under the repository's current owner), its bundle, and its SHA256SUMS, downloaded
580    // 2026-09-26 from the release's public URLs. `tests/release_bundle.rs` reads the same files.
581    const TAG: &str = "v0.7.0";
582    const BUNDLE: &str = include_str!("../../tests/fixtures/release-v0.7.0/agentd.sigstore.json");
583    const SUMS: &str = include_str!("../../tests/fixtures/release-v0.7.0/SHA256SUMS");
584
585    /// The asset's bytes. It's committed gzipped so the tree holds no executable: OpenSSF
586    /// Scorecard's Binary-Artifacts check counts every ELF file against the score, and a
587    /// gzip stream isn't one.
588    fn agentd() -> &'static [u8] {
589        static AGENTD: std::sync::LazyLock<Vec<u8>> = std::sync::LazyLock::new(|| {
590            use std::io::Read as _;
591            let packed = include_bytes!("../../tests/fixtures/release-v0.7.0/agentd.gz");
592            let mut bytes = Vec::new();
593            flate2::read::GzDecoder::new(&packed[..])
594                .read_to_end(&mut bytes)
595                .expect("the fixture is gzip");
596            bytes
597        });
598        &AGENTD
599    }
600
601    fn verifier() -> SigstoreVerifier {
602        SigstoreVerifier::public_good().expect("the embedded trusted root parses")
603    }
604
605    /// The fixture with one bit of one byte flipped.
606    fn flipped() -> Vec<u8> {
607        let mut bytes = agentd().to_vec();
608        bytes[1_000_000] ^= 0x01;
609        bytes
610    }
611
612    /// The bundle with one field of its first transparency log entry changed by `edit`.
613    fn tampered(edit: impl FnOnce(&mut serde_json::Value)) -> String {
614        let mut bundle: serde_json::Value =
615            serde_json::from_str(BUNDLE).expect("the fixture parses");
616        edit(&mut bundle["verificationMaterial"]["tlogEntries"][0]);
617        bundle.to_string()
618    }
619
620    /// Base64 `text` with one bit in its middle byte flipped.
621    fn flip_base64(value: &mut serde_json::Value) {
622        use base64::Engine as _;
623        let engine = base64::engine::general_purpose::STANDARD;
624        let mut bytes = engine
625            .decode(value.as_str().expect("a base64 string"))
626            .expect("valid base64");
627        let middle = bytes.len() / 2;
628        bytes[middle] ^= 0x01;
629        *value = serde_json::Value::String(engine.encode(bytes));
630    }
631
632    /// **A real release's asset verifies against its committed bundle** (#284): the positive
633    /// half, without which every refusal below could be a verifier that refuses everything.
634    #[test]
635    fn the_committed_release_verifies_against_its_bundle() {
636        verifier()
637            .verify(BUNDLE, agentd(), &Signer::release(TAG))
638            .expect("the v0.7.0 release asset is attested by its release workflow");
639        assert_eq!(
640            sha256_hex(agentd()),
641            "b281856000f58e6306bcaca73362e4d77260324a51f9b7ba3de563cf102dbe76",
642            "the fixture is the published asset"
643        );
644    }
645
646    /// **One flipped byte in the asset is refused** (BIND-18, #284).
647    #[test]
648    fn one_flipped_byte_in_the_asset_is_refused() {
649        let refusal = verifier()
650            .verify(BUNDLE, &flipped(), &Signer::release(TAG))
651            .expect_err("changed bytes must not verify");
652        assert!(refusal.contains("does not match any subject"), "{refusal}");
653    }
654
655    /// **A signer other than the release workflow at the requested tag is refused** (BIND-18): the
656    /// same bundle, checked against another tag (a downgrade), another workflow, and the
657    /// repository's former owner.
658    #[test]
659    fn another_signer_identity_is_refused() {
660        let release = Signer::release(TAG);
661        let others = [
662            Signer::release("v0.8.0"),
663            Signer {
664                identity: release.identity.replace("release.yml", "ci.yml"),
665                ..release.clone()
666            },
667            Signer {
668                identity: release.identity.replace("laithalsaadoon", "theagenticguy"),
669                ..release.clone()
670            },
671        ];
672        for signer in others {
673            let refusal = verifier()
674                .verify(BUNDLE, agentd(), &signer)
675                .expect_err(&signer.identity);
676            assert!(refusal.contains("identity mismatch"), "{refusal}");
677        }
678    }
679
680    /// Another OIDC issuer is refused.
681    #[test]
682    fn another_issuer_is_refused() {
683        let signer = Signer {
684            issuer: "https://accounts.google.com".into(),
685            ..Signer::release(TAG)
686        };
687        let refusal = verifier()
688            .verify(BUNDLE, agentd(), &signer)
689            .expect_err("another issuer must refuse");
690        assert!(refusal.contains("issuer mismatch"), "{refusal}");
691    }
692
693    /// A statement of another predicate type is refused, as `gh` refuses it.
694    #[test]
695    fn another_predicate_type_is_refused() {
696        let signer = Signer {
697            predicate_type: "https://spdx.dev/Document".into(),
698            ..Signer::release(TAG)
699        };
700        let refusal = verifier()
701            .verify(BUNDLE, agentd(), &signer)
702            .expect_err("another predicate type must refuse");
703        assert!(refusal.contains("predicate type mismatch"), "{refusal}");
704    }
705
706    /// **Tampered transparency log evidence is refused.** The reason this crate and not
707    /// `sigstore` 0.14, which accepted each of these (measured 2026-09-25). The first three
708    /// cases are the ones only the inclusion proof check sees, so they're what fails if the
709    /// policy ever turns on `skip_tlog_unsafe`; the SET and the integrated time it signs are
710    /// checked either way.
711    #[test]
712    fn tampered_transparency_evidence_is_refused() {
713        let cases = [
714            (
715                "proof hash",
716                tampered(|entry| flip_base64(&mut entry["inclusionProof"]["hashes"][0])),
717            ),
718            (
719                "checkpoint signature",
720                tampered(|entry| {
721                    let envelope = entry["inclusionProof"]["checkpoint"]["envelope"]
722                        .as_str()
723                        .expect("a checkpoint")
724                        .to_string();
725                    let (head, signature) = envelope.trim_end().rsplit_once(' ').expect("signed");
726                    let mut signature = serde_json::Value::String(signature.to_string());
727                    flip_base64(&mut signature);
728                    entry["inclusionProof"]["checkpoint"]["envelope"] = serde_json::Value::String(
729                        format!("{head} {}\n", signature.as_str().expect("a string")),
730                    );
731                }),
732            ),
733            (
734                "proof log index",
735                tampered(|entry| {
736                    let index: u64 = entry["inclusionProof"]["logIndex"]
737                        .as_str()
738                        .expect("a string")
739                        .parse()
740                        .expect("a number");
741                    entry["inclusionProof"]["logIndex"] =
742                        serde_json::Value::String((index + 1).to_string());
743                }),
744            ),
745            (
746                "proof root hash",
747                tampered(|entry| flip_base64(&mut entry["inclusionProof"]["rootHash"])),
748            ),
749            (
750                "SET",
751                tampered(|entry| {
752                    flip_base64(&mut entry["inclusionPromise"]["signedEntryTimestamp"])
753                }),
754            ),
755            (
756                "integrated time",
757                tampered(|entry| {
758                    let time: u64 = entry["integratedTime"]
759                        .as_str()
760                        .expect("a string")
761                        .parse()
762                        .expect("a number");
763                    entry["integratedTime"] = serde_json::Value::String((time + 60).to_string());
764                }),
765            ),
766        ];
767        for (what, bundle) in cases {
768            verifier()
769                .verify(&bundle, agentd(), &Signer::release(TAG))
770                .expect_err(what);
771        }
772    }
773
774    /// A bundle that isn't one is a refusal, not a panic.
775    #[test]
776    fn a_bundle_that_does_not_parse_is_refused() {
777        let refusal = verifier()
778            .verify("{\"not\": \"a bundle\"}", agentd(), &Signer::release(TAG))
779            .expect_err("must refuse");
780        assert!(refusal.contains("doesn't parse"), "{refusal}");
781    }
782
783    /// The committed release served from memory, with or without its bundle.
784    struct Fixture {
785        asset: Vec<u8>,
786        bundle: Option<Bundle>,
787    }
788
789    /// What the fixture's lookup answers when it doesn't serve the bundle.
790    #[derive(Clone, Copy, Debug)]
791    enum Bundle {
792        Unreachable,
793        Absent,
794    }
795
796    impl ReleaseSource for Fixture {
797        fn asset(&self, tag: &str, name: &str) -> Result<Vec<u8>, String> {
798            assert_eq!((tag, name), (TAG, ASSET));
799            Ok(self.asset.clone())
800        }
801
802        fn checksums(&self, _: &str) -> Result<String, String> {
803            Ok(SUMS.to_string())
804        }
805
806        fn attestations(&self, _: &str, _: &str) -> Bundles {
807            match self.bundle {
808                None => Bundles::Published(vec![BUNDLE.to_string()]),
809                Some(Bundle::Unreachable) => Bundles::Unreachable("HTTP 403".to_string()),
810                Some(Bundle::Absent) => Bundles::Absent("HTTP 404".to_string()),
811            }
812        }
813    }
814
815    /// The policy over the real verifier and the committed release: attestation when the
816    /// bundle is there, the checksum when it can't be reached, a refusal when the release says
817    /// it has none, and a refusal for flipped bytes either way.
818    #[test]
819    fn the_policy_over_the_committed_release() {
820        let fetch = |asset: Vec<u8>, bundle: Option<Bundle>| {
821            fetch_release(&Fixture { asset, bundle }, &verifier(), TAG, &mut |_| {})
822        };
823        let attested = fetch(agentd().to_vec(), None).expect("attested");
824        assert_eq!(attested.verification, Verification::Attestation);
825        assert_eq!(attested.bytes, agentd());
826        let checked = fetch(agentd().to_vec(), Some(Bundle::Unreachable)).expect("checked");
827        assert_eq!(checked.verification, Verification::Checksum);
828        let refusal = fetch(agentd().to_vec(), Some(Bundle::Absent))
829            .expect_err("a release that says it has no bundle is refused");
830        assert!(refusal.contains("publishes no attestation"), "{refusal}");
831
832        let refusal = fetch(flipped(), None).expect_err("flipped bytes, bundle there");
833        assert!(refusal.contains("discarded"), "{refusal}");
834        let refusal = fetch(flipped(), Some(Bundle::Unreachable)).expect_err("flipped, no bundle");
835        assert!(refusal.contains("SHA256 mismatch"), "{refusal}");
836    }
837
838    /// What [`GitHubRelease::lookup`]'s scripted `GET` answers for one URL.
839    #[derive(Clone, Copy, Debug)]
840    enum Answer {
841        /// 200 with the committed bundle, whole or wrapped as the API lists it.
842        Bundle,
843        /// 200 with an empty attestations list.
844        EmptyList,
845        /// 200 with junk.
846        Junk,
847        Status(u16),
848        /// No response at all.
849        Transport,
850    }
851
852    /// The lookup over a `GET` that answers the bundle asset with `asset` and the API with
853    /// `api`, and the token each request carried.
854    fn lookup(token: Option<&str>, asset: Answer, api: Answer) -> (Bundles, Vec<Option<String>>) {
855        let tokens = std::cell::RefCell::new(Vec::new());
856        let get = |url: &str, token: Option<&str>, _: usize| {
857            tokens.borrow_mut().push(token.map(str::to_string));
858            let (answer, from_api) = if url == asset_url(TAG, BUNDLE_ASSET) {
859                (asset, false)
860            } else {
861                assert_eq!(url, attestations_url("abc"), "only the two lookups run");
862                (api, true)
863            };
864            let bundle: serde_json::Value = serde_json::from_str(BUNDLE).expect("parses");
865            match answer {
866                Answer::Bundle if from_api => Ok(serde_json::json!({
867                    "attestations": [{ "bundle": bundle }]
868                })
869                .to_string()
870                .into_bytes()),
871                Answer::Bundle => Ok(BUNDLE.as_bytes().to_vec()),
872                Answer::EmptyList => Ok(br#"{"attestations": []}"#.to_vec()),
873                Answer::Junk => Ok(b"\xff<html>".to_vec()),
874                Answer::Status(status) => Err(HttpFailure {
875                    status: Some(status),
876                    message: format!("GET {url}: HTTP {status}"),
877                }),
878                Answer::Transport => Err(HttpFailure {
879                    status: None,
880                    message: format!("GET {url}: error sending request: dns error"),
881                }),
882            }
883        };
884        let found = GitHubRelease::new(token.map(str::to_string)).lookup(TAG, "abc", &get);
885        (found, tokens.into_inner())
886    }
887
888    /// **Only a "not found" is an answer** (BIND-18, #284). Deleting the bundle asset, or
889    /// replacing it with junk, refuses the bytes; a rate limit, a server error or a failed
890    /// connection is the only road to the checksum; and the API's bundle is used when the
891    /// asset's can't be had.
892    #[test]
893    fn a_missing_bundle_is_absent_and_a_failed_request_is_unreachable() {
894        use Answer::*;
895        let published = |found: &Bundles| matches!(found, Bundles::Published(b) if b.len() == 1);
896        let absent = |found: &Bundles| matches!(found, Bundles::Absent(_));
897        let unreachable = |found: &Bundles| matches!(found, Bundles::Unreachable(_));
898        type Row<'a> = (Answer, Answer, &'a dyn Fn(&Bundles) -> bool);
899        let rows: [Row<'_>; 14] = [
900            (Bundle, Status(500), &published),
901            // A bundle asset that downloads is the bundle, junk included: the verifier refuses
902            // junk, and skipping it for the API would let junk stand in for a deleted bundle.
903            (Junk, Status(404), &published),
904            (Junk, Bundle, &published),
905            (Status(404), Bundle, &published),
906            (Status(503), Bundle, &published),
907            (Status(404), Status(404), &absent),
908            (Status(404), EmptyList, &absent),
909            (Status(404), Status(403), &absent),
910            (Status(404), Transport, &absent),
911            (Transport, Status(404), &absent),
912            (Status(403), EmptyList, &absent),
913            (Status(403), Status(429), &unreachable),
914            (Transport, Status(502), &unreachable),
915            (Status(500), Junk, &unreachable),
916        ];
917        for (asset, api, expected) in rows {
918            let (found, _) = lookup(None, asset, api);
919            assert!(expected(&found), "asset {asset:?}, API {api:?}: {found:?}");
920        }
921        let (found, _) = lookup(None, Status(404), Bundle);
922        let Bundles::Published(bundles) = found else {
923            panic!("{found:?}")
924        };
925        verifier()
926            .verify(&bundles[0], agentd(), &Signer::release(TAG))
927            .expect("the API's bundle verifies like the asset's");
928    }
929
930    /// The token goes to the API and nowhere else, and a rate limit without one says to set
931    /// it.
932    #[test]
933    fn the_token_goes_only_to_the_api() {
934        let (_, tokens) = lookup(Some("ghp_secret"), Answer::Status(404), Answer::Bundle);
935        assert_eq!(tokens, [None, Some("ghp_secret".to_string())]);
936        let (found, _) = lookup(None, Answer::Transport, Answer::Status(403));
937        let Bundles::Unreachable(reason) = found else {
938            panic!("{found:?}")
939        };
940        assert!(reason.contains("set GITHUB_TOKEN"), "{reason}");
941        let (found, _) = lookup(Some("t"), Answer::Transport, Answer::Status(403));
942        assert!(
943            !format!("{found:?}").contains("set GITHUB_TOKEN"),
944            "{found:?}"
945        );
946    }
947
948    /// The API's response shape: a list of attestations, each carrying its bundle inline.
949    #[test]
950    fn the_api_response_yields_each_bundle() {
951        let bundle: serde_json::Value = serde_json::from_str(BUNDLE).expect("parses");
952        let body = serde_json::json!({
953            "attestations": [
954                { "bundle": bundle, "repository_id": 1 },
955                { "bundle_url": "https://example.invalid/only-a-url" },
956            ]
957        });
958        let only_urls = r#"{"attestations": [{"bundle_url": "https://example.invalid/x"}]}"#;
959        assert!(parse_attestations(only_urls.as_bytes()).is_err());
960        let bundles = parse_attestations(body.to_string().as_bytes()).expect("parses");
961        assert_eq!(bundles.len(), 1);
962        verifier()
963            .verify(&bundles[0], agentd(), &Signer::release(TAG))
964            .expect("the API's bundle verifies like the asset's");
965
966        assert_eq!(
967            parse_attestations(br#"{"attestations": []}"#).expect("parses"),
968            Vec::<String>::new()
969        );
970        assert!(parse_attestations(br#"{"message": "Not Found"}"#).is_err());
971        assert!(parse_attestations(b"<html>").is_err());
972    }
973
974    /// The URLs the source reads, spelled out so a change to either is a visible diff.
975    #[test]
976    fn the_urls_are_the_public_release_and_the_attestations_api() {
977        assert_eq!(
978            asset_url("v0.7.0", BUNDLE_ASSET),
979            "https://github.com/laithalsaadoon/microvms-agentd/releases/download/v0.7.0/\
980             agentd.sigstore.json"
981        );
982        assert_eq!(
983            attestations_url("abc"),
984            "https://api.github.com/repos/laithalsaadoon/microvms-agentd/attestations/sha256:abc"
985        );
986    }
987
988    /// The token comes from `GITHUB_TOKEN`, an empty one is none, and neither appears in a
989    /// debug print.
990    #[test]
991    fn the_token_is_read_from_the_environment_and_never_printed() {
992        let with =
993            GitHubRelease::from_env(&|name| (name == TOKEN_VARIABLE).then(|| "ghp_secret".into()));
994        assert_eq!(with.token.as_deref(), Some("ghp_secret"));
995        let printed = format!(
996            "{:?}",
997            HttpsFetch {
998                release: with,
999                directory: None,
1000            }
1001        );
1002        assert!(!printed.contains("ghp_secret"), "{printed}");
1003        assert!(printed.contains("redacted"), "{printed}");
1004        assert_eq!(
1005            GitHubRelease::from_env(&|_| Some(String::new())).token,
1006            None
1007        );
1008        assert_eq!(GitHubRelease::from_env(&|_| None).token, None);
1009        let fetch = HttpsFetch::from_env(&|name| (name == TOKEN_VARIABLE).then(|| "t".into()));
1010        assert_eq!(fetch.release.token.as_deref(), Some("t"));
1011    }
1012
1013    /// The committed release in `<root>/<tag>/`, laid out as `gh release download --dir`
1014    /// writes it, with or without its bundle.
1015    fn release_dir(tag: &str, bundle: bool) -> tempfile::TempDir {
1016        let root = tempfile::tempdir().expect("a temp dir");
1017        let dir = root.path().join(tag);
1018        std::fs::create_dir(&dir).expect("the tag's directory");
1019        std::fs::write(dir.join(ASSET), agentd()).expect("the asset");
1020        std::fs::write(dir.join(CHECKSUMS), SUMS).expect("the checksums");
1021        if bundle {
1022            std::fs::write(dir.join(BUNDLE_ASSET), BUNDLE).expect("the bundle");
1023        }
1024        root
1025    }
1026
1027    /// The shipped fetch with `$MICROVM_RELEASE_DIR` naming `root`.
1028    fn from_dir(root: &Path) -> HttpsFetch {
1029        let root = root.display().to_string();
1030        HttpsFetch::from_env(&|name| (name == RELEASE_DIR_VARIABLE).then(|| root.clone()))
1031    }
1032
1033    /// **A release read from `$MICROVM_RELEASE_DIR` is proven by its attestation** (BIND-18):
1034    /// the committed release on disk verifies against its own bundle and lands at the
1035    /// destination, and the first progress line names where it was read from.
1036    #[test]
1037    fn a_release_directory_is_proven_by_its_attestation() {
1038        let root = release_dir(TAG, true);
1039        let dest = root.path().join("installed");
1040        let mut lines = Vec::new();
1041        let verification = from_dir(root.path())
1042            .fetch(TAG, &dest, &mut |line| lines.push(line.to_string()))
1043            .expect("the directory's release verifies");
1044        assert_eq!(verification, Verification::Attestation);
1045        assert_eq!(std::fs::read(&dest).expect("installed"), agentd());
1046        let dir = root.path().join(TAG).display().to_string();
1047        assert!(
1048            lines[0].contains(&dir) && lines[0].contains(RELEASE_DIR_VARIABLE),
1049            "{lines:?}"
1050        );
1051    }
1052
1053    /// **A release directory without its bundle is refused, even with a matching
1054    /// `SHA256SUMS`** (BIND-18): a directory always answers, so the checksum is never
1055    /// consulted, and nothing is installed.
1056    #[test]
1057    fn a_release_directory_without_its_bundle_is_refused_despite_a_matching_checksum() {
1058        let root = release_dir(TAG, false);
1059        let dest = root.path().join("installed");
1060        let refusal = from_dir(root.path())
1061            .fetch(TAG, &dest, &mut |_| {})
1062            .expect_err("no bundle, no proof");
1063        assert!(refusal.contains("publishes no attestation"), "{refusal}");
1064        assert!(refusal.contains(RELEASE_DIR_VARIABLE), "{refusal}");
1065        assert!(!dest.exists());
1066    }
1067
1068    /// **Another release's files under the requested tag are refused** (BIND-18): the genuine
1069    /// v0.7.0 laid out as v0.8.0 fails the signer, which is pinned to the tag asked for, so a
1070    /// directory can't pass an older release off as a newer one.
1071    #[test]
1072    fn another_releases_files_under_the_requested_tag_are_refused() {
1073        let root = release_dir("v0.8.0", true);
1074        let dest = root.path().join("installed");
1075        let refusal = from_dir(root.path())
1076            .fetch("v0.8.0", &dest, &mut |_| {})
1077            .expect_err("v0.7.0's bundle doesn't prove v0.8.0");
1078        assert!(refusal.contains("identity mismatch"), "{refusal}");
1079        assert!(!dest.exists());
1080    }
1081
1082    /// A set variable is the source: a directory without the requested release is an error
1083    /// naming the missing file, not a fetch from GitHub. An empty variable is unset.
1084    #[test]
1085    fn a_release_directory_without_the_release_is_an_error_and_an_empty_one_is_unset() {
1086        let root = tempfile::tempdir().expect("a temp dir");
1087        let refusal = from_dir(root.path())
1088            .fetch(TAG, &root.path().join("installed"), &mut |_| {})
1089            .expect_err("nothing to read");
1090        let missing = root.path().join(TAG).join(ASSET).display().to_string();
1091        assert!(refusal.contains(&missing), "{refusal}");
1092        assert!(
1093            HttpsFetch::from_env(&|_| Some(String::new()))
1094                .directory
1095                .is_none()
1096        );
1097        assert!(HttpsFetch::from_env(&|_| None).directory.is_none());
1098    }
1099
1100    /// The directory's `SHA256SUMS` reads back whole, though the policy never asks for it from
1101    /// a directory, and a file over its limit is refused while one at the limit is read.
1102    #[test]
1103    fn a_release_directory_reads_its_checksums_and_refuses_a_file_over_its_limit() {
1104        let root = release_dir(TAG, true);
1105        let directory = DirectoryRelease {
1106            root: root.path().to_path_buf(),
1107        };
1108        assert_eq!(directory.checksums(TAG).as_deref(), Ok(SUMS));
1109        let file = root.path().join("four");
1110        std::fs::write(&file, b"four").expect("a file");
1111        assert_eq!(read_limited(&file, 4).as_deref(), Ok(&b"four"[..]));
1112        let refusal = read_limited(&file, 3).expect_err("over the limit");
1113        assert!(refusal.contains("over 3 bytes"), "{refusal}");
1114    }
1115
1116    /// A request runs from inside a runtime too, which is where the CLI calls it from. A
1117    /// runtime started on a runtime's own thread would panic here instead of answering.
1118    #[tokio::test]
1119    async fn a_request_runs_from_inside_a_runtime() {
1120        let answer = run(async { Ok::<_, HttpFailure>(7) });
1121        assert_eq!(answer, Ok(7));
1122    }
1123
1124    /// Only a success status starts a body, and a refusal carries the status, which is how
1125    /// [`GitHubRelease::lookup`] tells a 404 from a rate limit.
1126    #[test]
1127    fn a_status_other_than_success_is_refused_with_the_status() {
1128        let start = |status: u16| {
1129            let status = reqwest::StatusCode::from_u16(status).expect("a status");
1130            Body::start("https://example.invalid/x", status, None, 8).map(|body| body.bytes)
1131        };
1132        assert_eq!(start(200), Ok(Vec::new()));
1133        assert_eq!(start(204), Ok(Vec::new()));
1134        for status in [301, 403, 404, 429, 500] {
1135            assert_eq!(
1136                start(status),
1137                Err(HttpFailure {
1138                    status: Some(status),
1139                    message: format!("GET https://example.invalid/x: HTTP {status}"),
1140                }),
1141            );
1142        }
1143    }
1144
1145    /// A body over its limit is refused, whether the server declares its length up front or
1146    /// sends more than the limit without saying, and a body of exactly the limit is read whole.
1147    #[test]
1148    fn a_body_over_its_limit_is_refused() {
1149        let over = Err(HttpFailure {
1150            status: None,
1151            message: "GET https://example.invalid/x: the body is over 4 bytes".to_string(),
1152        });
1153        let start = |length: Option<u64>| {
1154            Body::start(
1155                "https://example.invalid/x",
1156                reqwest::StatusCode::OK,
1157                length,
1158                4,
1159            )
1160        };
1161        for length in [None, Some(0), Some(3), Some(4)] {
1162            assert!(start(length).is_ok(), "declared {length:?}");
1163        }
1164        assert_eq!(start(Some(5)).map(|body| body.bytes), over);
1165
1166        let read = |chunks: &[&[u8]]| {
1167            let mut body = start(None)?;
1168            for chunk in chunks {
1169                body.push(chunk)?;
1170            }
1171            Ok(body.bytes)
1172        };
1173        assert_eq!(read(&[b"ab", b"cd"]), Ok(b"abcd".to_vec()));
1174        assert_eq!(read(&[b"abcd", b""]), Ok(b"abcd".to_vec()));
1175        assert_eq!(read(&[b"abcde"]), over);
1176        assert_eq!(read(&[b"ab", b"cd", b"e"]), over);
1177        assert_eq!(read(&[b"a", b"a", b"a", b"a", b"a"]), over);
1178    }
1179
1180    /// The limits admit the committed release, so a change to one that turns a real release
1181    /// away fails here rather than on a user's first fetch.
1182    #[test]
1183    fn the_limits_admit_a_real_release() {
1184        assert!(agentd().len() <= ASSET_LIMIT, "{} bytes", agentd().len());
1185        assert!(BUNDLE.len() <= SMALL_LIMIT, "{} bytes", BUNDLE.len());
1186        assert!(SUMS.len() <= SMALL_LIMIT, "{} bytes", SUMS.len());
1187        let listed = format!(r#"{{"attestations": [{{"bundle": {BUNDLE}}}]}}"#);
1188        assert!(listed.len() <= API_LIMIT, "{} bytes", listed.len());
1189    }
1190
1191    /// An error's causes are on the line, since reqwest's own message stops before the one a
1192    /// reader acts on.
1193    #[test]
1194    fn an_error_is_printed_with_its_causes() {
1195        #[derive(Debug)]
1196        struct Layer(&'static str, Option<Box<Layer>>);
1197        impl fmt::Display for Layer {
1198            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1199                f.write_str(self.0)
1200            }
1201        }
1202        impl std::error::Error for Layer {
1203            fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1204                self.1.as_deref().map(|cause| cause as _)
1205            }
1206        }
1207        let error = Layer(
1208            "error sending request",
1209            Some(Box::new(Layer(
1210                "dns error",
1211                Some(Box::new(Layer("no such host", None))),
1212            ))),
1213        );
1214        assert_eq!(
1215            chain(&error),
1216            "error sending request: dns error: no such host"
1217        );
1218        assert_eq!(chain(&Layer("timed out", None)), "timed out");
1219    }
1220
1221    /// The verifier's debug print says which trusted root it holds; the root itself isn't
1222    /// `Debug`.
1223    #[test]
1224    fn the_verifier_prints_the_root_it_holds() {
1225        assert_eq!(format!("{:?}", verifier()), "SigstoreVerifier(public-good)");
1226    }
1227}