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