Skip to main content

microvms_core/
provision.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Provisioning of the `agentd` daemon binary: one call that returns verified aarch64
3//! bytes for the version a client drives (BIND-17 through BIND-20, issue #219).
4//!
5//! # Why the client stack fetches its own daemon
6//!
7//! The daemon is this product's own component, versioned in this workspace and shipped
8//! from this repository's releases. A harness that bakes it into an image should not need
9//! a hard-coded release URL, an ELF check, and a cache of its own; `docker run` takes an
10//! image name, and Dagger's CLI provisions its matching-version engine itself. So
11//! [`resolve`] answers a request from, in order:
12//!
13//! 1. a caller-supplied path: [`Request::binary`], else `$MICROVM_AGENTD` (a path, for the
14//!    caller who manages the binary themselves);
15//! 2. the cache entry for the requested version under the state directory;
16//! 3. a fetch of this repository's GitHub release asset for that version.
17//!
18//! The version defaults to [`crate::VERSION`], the core's own. The daemon and the client
19//! share one workspace version, so `v{VERSION}` is the one tag whose protocol this client
20//! is proven against; a "latest" fetch would reintroduce exactly the skew the shared
21//! version exists to prevent.
22//!
23//! A refused caller-supplied binary is an error, never a fall-through to the cache or a
24//! fetch: a caller who named a binary manages it, and running a different one would run a
25//! daemon they did not choose.
26//!
27//! # What proves a fetch
28//!
29//! The download goes through a subprocess the caller can see in `ps`, and the CLI's
30//! `tests/thinness.rs` is why the policy was written that way: that crate may hold no HTTP
31//! client, and the policy moved here unchanged. [`ReleaseFetch`] runs two tools, in
32//! preference order:
33//!
34//! - **`gh`**, because `gh attestation verify` checks the Sigstore attestation the release
35//!   workflow published for the asset: provenance, not just integrity. A verification
36//!   failure after a successful download is a **hard stop**, never a fall-through: bytes
37//!   that exist but do not verify are the one state a fallback must not launder.
38//! - **`curl`**, because `gh` refuses to run unauthenticated even against a public
39//!   repository. This path checks the download against the release's `SHA256SUMS` asset,
40//!   hashed in-process: integrity against corruption, weaker than provenance, and
41//!   [`Verification`] says which of the two the caller got. A release with no
42//!   `SHA256SUMS` (every tag before v0.5.0) fails closed.
43//!
44//! A fetch that cannot be verified is an error (BIND-18), never a warning.
45//!
46//! # What is checked no matter where the bytes came from
47//!
48//! Every binary this module returns is an aarch64 ELF (BIND-20): MicroVMs are ARM64-only,
49//! and a wrong binary baked into an image fails 45 minutes later as a run-hook timeout that
50//! says nothing about architecture. Twenty bytes of header now is the whole cost of never
51//! finding out.
52//!
53//! # The cache serves only what it verified
54//!
55//! An install is write-to-partial-then-rename, so an interrupted download never leaves a
56//! truncated binary where the next request reads. Beside each entry sits a digest record
57//! written after verification, and a cache hit is served only when the bytes still hash to
58//! it (BIND-19). An entry that does not match, or that has no record (one an older client
59//! installed), is discarded and fetched again. The record lives in the same directory as
60//! the binary, so this catches corruption and a binary copied over the entry, not a writer
61//! who replaces both; such a writer could replace a caller-supplied binary just as well.
62//!
63//! `model/src/provision.rs` checks this policy against the ways it could be written
64//! instead, and `tests/features/provision.feature` drives it through a fake release.
65//!
66//! # The seams
67//!
68//! [`Fetch`] is the download-and-prove seam the CLI's guards script, and [`Runner`] is the
69//! subprocess seam under [`ReleaseFetch`], which the Gherkin runner answers with a fake
70//! release. Neither lets a test open a socket to GitHub.
71
72use std::fmt;
73use std::path::{Path, PathBuf};
74
75use crate::error::{Error, ErrorKind};
76
77/// The repository whose releases carry the daemon asset.
78pub const RELEASE_REPO: &str = "laithalsaadoon/microvms-agentd";
79
80/// The release asset's name: a literal, because the README's `--pattern agentd` and the
81/// checksum lookup both match it exactly.
82pub const ASSET: &str = "agentd";
83
84/// The environment variable naming a caller-managed binary, used when a request carries
85/// no [`Request::binary`].
86pub const ENV_OVERRIDE: &str = "MICROVM_AGENTD";
87
88/// The `e_machine` of an aarch64 ELF (`EM_AARCH64`).
89pub const REQUIRED_ELF_MACHINE: u16 = 0xB7;
90
91/// The digest record's file name, beside the cached binary.
92const RECORD: &str = "agentd.verified.json";
93
94/// The longest version string accepted. Release tags are short; a bound keeps a hostile
95/// value from becoming a long path component.
96const MAX_VERSION_LEN: usize = 64;
97
98/// How a fetched binary's bytes were proven.
99#[derive(Clone, Copy, Debug, PartialEq, Eq)]
100pub enum Verification {
101    /// `gh attestation verify`: the bytes carry the release workflow's Sigstore
102    /// attestation for this repository.
103    Attestation,
104    /// The release's `SHA256SUMS` entry matched, hashed in-process: integrity against a
105    /// corrupted or truncated download, not provenance.
106    Checksum,
107}
108
109impl Verification {
110    pub fn as_str(self) -> &'static str {
111        match self {
112            Verification::Attestation => "attestation",
113            Verification::Checksum => "checksum",
114        }
115    }
116
117    fn parse(text: &str) -> Option<Self> {
118        match text {
119            "attestation" => Some(Verification::Attestation),
120            "checksum" => Some(Verification::Checksum),
121            _ => None,
122        }
123    }
124}
125
126/// How a caller supplied a binary.
127#[derive(Clone, Copy, Debug, PartialEq, Eq)]
128pub enum Supplier {
129    /// [`Request::binary`].
130    Argument,
131    /// `$MICROVM_AGENTD`.
132    Env,
133}
134
135impl Supplier {
136    pub fn as_str(self) -> &'static str {
137        match self {
138            Supplier::Argument => "argument",
139            Supplier::Env => "env",
140        }
141    }
142}
143
144/// Where a provisioned binary came from.
145#[derive(Clone, Copy, Debug, PartialEq, Eq)]
146pub enum Source {
147    /// The caller's own binary. Its provenance is the caller's business, so it carries no
148    /// [`Verification`]; it is checked for aarch64 like everything else.
149    CallerSupplied(Supplier),
150    /// The cache entry for the requested version, whose bytes still match the digest
151    /// recorded when this verification passed.
152    Cache(Verification),
153    /// Fetched from the GitHub release during this call.
154    Fetched(Verification),
155}
156
157impl Source {
158    /// `"caller-supplied"`, `"cache"`, or `"fetched"`.
159    pub fn as_str(self) -> &'static str {
160        match self {
161            Source::CallerSupplied(_) => "caller-supplied",
162            Source::Cache(_) => "cache",
163            Source::Fetched(_) => "fetched",
164        }
165    }
166
167    /// How the bytes were proven: when fetched, or when the cache entry was installed.
168    /// `None` for a caller-supplied binary.
169    pub fn verification(self) -> Option<Verification> {
170        match self {
171            Source::CallerSupplied(_) => None,
172            Source::Cache(verification) | Source::Fetched(verification) => Some(verification),
173        }
174    }
175}
176
177/// A provisioned daemon binary and how it got here.
178#[derive(Clone, PartialEq, Eq)]
179pub struct Provisioned {
180    /// The binary itself, an aarch64 ELF.
181    pub bytes: Vec<u8>,
182    /// Where it is on disk: the cache entry, or the caller's path.
183    pub path: PathBuf,
184    pub source: Source,
185    /// The version requested, without a leading `v`. For a caller-supplied binary, the
186    /// version asked for, which nothing checks the binary against.
187    pub version: String,
188    /// The lowercase hex SHA-256 of [`Self::bytes`].
189    pub sha256: String,
190}
191
192impl Provisioned {
193    /// How the bytes were proven, or `None` for a caller-supplied binary.
194    pub fn verification(&self) -> Option<Verification> {
195        self.source.verification()
196    }
197}
198
199/// Written by hand so a debug print never dumps megabytes of binary.
200impl fmt::Debug for Provisioned {
201    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202        f.debug_struct("Provisioned")
203            .field("bytes", &format_args!("<{} bytes>", self.bytes.len()))
204            .field("path", &self.path)
205            .field("source", &self.source)
206            .field("version", &self.version)
207            .field("sha256", &self.sha256)
208            .finish()
209    }
210}
211
212/// What a caller asks for. Every field is optional; [`Request::default`] asks for the
213/// core's own version under the default state directory.
214#[derive(Clone, Copy, Debug, Default)]
215pub struct Request<'a> {
216    /// The release version, with or without a leading `v`. Defaults to [`crate::VERSION`].
217    pub version: Option<&'a str>,
218    /// The state directory the cache lives under. Defaults to the CLI's
219    /// ([`crate::names::default_state_root`]), so every surface shares one cache.
220    pub state_dir: Option<&'a Path>,
221    /// A binary the caller manages. Outranks `$MICROVM_AGENTD`.
222    pub binary: Option<&'a Path>,
223}
224
225/// Which step refused, so a caller can attach its own remedies.
226#[derive(Clone, Copy, Debug, PartialEq, Eq)]
227pub enum Failure {
228    /// The version is not a release tag.
229    InvalidVersion,
230    /// The caller's path names nothing, or cannot be read.
231    CallerBinary(Supplier),
232    /// The binary is not an aarch64 ELF.
233    NotAarch64,
234    /// The release could not be downloaded, or its bytes could not be verified.
235    Fetch,
236    /// A filesystem operation under the state directory failed.
237    Io,
238}
239
240/// A refusal, with the remedies a person or agent can act on.
241#[derive(Clone, Debug, PartialEq, Eq)]
242pub struct ProvisionError {
243    pub failure: Failure,
244    pub message: String,
245    pub remedies: Vec<String>,
246}
247
248impl ProvisionError {
249    fn new(failure: Failure, message: impl Into<String>) -> Self {
250        Self {
251            failure,
252            message: message.into(),
253            remedies: Vec::new(),
254        }
255    }
256
257    fn remedy(mut self, remedy: impl Into<String>) -> Self {
258        self.remedies.push(remedy.into());
259        self
260    }
261
262    /// The error-taxonomy row: `ERR_INVALID_ARG` for a version that is not a tag,
263    /// `ERR_PRECONDITION` for everything else, since no AWS call has been spent.
264    pub fn kind(&self) -> ErrorKind {
265        match self.failure {
266            Failure::InvalidVersion => ErrorKind::InvalidArg,
267            _ => ErrorKind::Precondition,
268        }
269    }
270}
271
272impl fmt::Display for ProvisionError {
273    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
274        f.write_str(&self.message)
275    }
276}
277
278impl std::error::Error for ProvisionError {}
279
280/// The taxonomy error a binding raises: the message, then each remedy, since a binding's
281/// exception has nowhere else to carry them.
282impl From<ProvisionError> for Error {
283    fn from(error: ProvisionError) -> Self {
284        let mut message = error.message.clone();
285        for remedy in &error.remedies {
286            message.push_str("\n  - ");
287            message.push_str(remedy);
288        }
289        Error::new(error.kind(), message)
290    }
291}
292
293/// The download-and-prove seam. The shipped client carries [`SubprocessFetch`]; the CLI's
294/// guards script this, which keeps `cargo test` off the network the way its `CoreSeam`
295/// keeps it off AWS.
296pub trait Fetch {
297    /// Download release `tag`'s daemon asset to `dest` and prove the bytes, reporting
298    /// progress lines as it goes. The error is prose: the caller owns the remedies.
299    fn fetch(
300        &self,
301        tag: &str,
302        dest: &Path,
303        progress: &mut dyn FnMut(&str),
304    ) -> Result<Verification, String>;
305}
306
307/// The subprocess seam under [`ReleaseFetch`]: run an argv, folding a spawn failure and a
308/// non-zero exit into one prose reason.
309pub trait Runner {
310    fn run(&self, argv: &[String]) -> Result<(), String>;
311}
312
313/// A borrowed runner runs the same way, so a caller can keep and inspect its own.
314impl<R: Runner + ?Sized> Runner for &R {
315    fn run(&self, argv: &[String]) -> Result<(), String> {
316        (**self).run(argv)
317    }
318}
319
320/// The shipped [`Runner`]: `std::process::Command`.
321#[derive(Clone, Copy, Debug, Default)]
322pub struct Subprocess;
323
324impl Runner for Subprocess {
325    fn run(&self, argv: &[String]) -> Result<(), String> {
326        let output = std::process::Command::new(&argv[0])
327            .args(&argv[1..])
328            .stdin(std::process::Stdio::null())
329            .output()
330            .map_err(|error| format!("`{}` did not run: {error}", argv[0]))?;
331        if output.status.success() {
332            return Ok(());
333        }
334        let detail = String::from_utf8_lossy(&output.stderr);
335        let detail = detail.trim();
336        Err(format!(
337            "`{}` exited {}: {}",
338            argv.join(" "),
339            output.status.code().unwrap_or(-1),
340            if detail.is_empty() {
341                "(no stderr)"
342            } else {
343                detail
344            },
345        ))
346    }
347}
348
349/// The verification policy over a [`Runner`]: `gh` for provenance, `curl` and
350/// `SHA256SUMS` for integrity when `gh` cannot download. See the module docs.
351#[derive(Clone, Copy, Debug, Default)]
352pub struct ReleaseFetch<R>(pub R);
353
354impl<R: Runner> Fetch for ReleaseFetch<R> {
355    fn fetch(
356        &self,
357        tag: &str,
358        dest: &Path,
359        progress: &mut dyn FnMut(&str),
360    ) -> Result<Verification, String> {
361        let runner = &self.0;
362        // `gh` first. Any failure to *download* falls through to curl: `gh` refuses to run
363        // unauthenticated even against a public repository, and that refusal must not cost
364        // an unauthenticated machine the feature.
365        match runner.run(&gh_download_args(tag, dest)) {
366            Ok(()) => {
367                progress(&format!(
368                    "downloaded {ASSET} {tag} via gh; verifying provenance"
369                ));
370                // A verification failure after a successful download is the one hard stop:
371                // falling through to curl here would launder bytes that failed provenance
372                // into a weaker check that cannot see what was wrong with them.
373                return match runner.run(&gh_verify_args(dest)) {
374                    Ok(()) => Ok(Verification::Attestation),
375                    Err(reason) => {
376                        let _ = std::fs::remove_file(dest);
377                        Err(format!(
378                            "`gh attestation verify` refused the downloaded asset: {reason}. \
379                             The bytes were discarded; do not retry with verification off."
380                        ))
381                    }
382                };
383            }
384            Err(gh_reason) => {
385                progress(&format!("gh could not download ({gh_reason}); trying curl"));
386            }
387        }
388
389        runner
390            .run(&curl_args(&asset_url(tag, ASSET), dest))
391            .map_err(|curl_reason| {
392                format!(
393                    "neither tool could download {ASSET} {tag} from {RELEASE_REPO}: gh and \
394                     curl both failed, most recently: {curl_reason}"
395                )
396            })?;
397        // Integrity, fail-closed: a release without SHA256SUMS (every tag before v0.5.0)
398        // refuses rather than trusting TLS alone.
399        let sums_dest = dest.with_extension("sums");
400        let sums = runner
401            .run(&curl_args(&asset_url(tag, "SHA256SUMS"), &sums_dest))
402            .and_then(|()| std::fs::read_to_string(&sums_dest).map_err(|error| error.to_string()));
403        let _ = std::fs::remove_file(&sums_dest);
404        let sums = sums.map_err(|reason| {
405            let _ = std::fs::remove_file(dest);
406            format!(
407                "downloaded {ASSET} {tag} via curl, but could not fetch the release's \
408                 SHA256SUMS to verify it ({reason}). curl alone proves nothing about the \
409                 bytes, so this fails closed. Releases before v0.5.0 ship no SHA256SUMS; \
410                 for those, authenticate `gh` and retry, or download and verify manually."
411            )
412        })?;
413        let bytes = std::fs::read(dest).map_err(|error| error.to_string())?;
414        if let Err(reason) = verify_sha256(&sums, ASSET, &bytes) {
415            let _ = std::fs::remove_file(dest);
416            return Err(reason);
417        }
418        progress(&format!(
419            "downloaded {ASSET} {tag} via curl; SHA256SUMS entry matched"
420        ));
421        Ok(Verification::Checksum)
422    }
423}
424
425/// The shipped fetcher: [`ReleaseFetch`] over real subprocesses.
426#[derive(Clone, Copy, Debug, Default)]
427pub struct SubprocessFetch;
428
429impl Fetch for SubprocessFetch {
430    fn fetch(
431        &self,
432        tag: &str,
433        dest: &Path,
434        progress: &mut dyn FnMut(&str),
435    ) -> Result<Verification, String> {
436        ReleaseFetch(Subprocess).fetch(tag, dest, progress)
437    }
438}
439
440/// The public download URL for `asset` on release `tag`: what curl gets, since it cannot
441/// speak the release API without a token.
442fn asset_url(tag: &str, asset: &str) -> String {
443    format!("https://github.com/{RELEASE_REPO}/releases/download/{tag}/{asset}")
444}
445
446/// `gh release download` argv, spelled as the README's manual command.
447fn gh_download_args(tag: &str, dest: &Path) -> Vec<String> {
448    vec![
449        "gh".into(),
450        "release".into(),
451        "download".into(),
452        tag.into(),
453        "--repo".into(),
454        RELEASE_REPO.into(),
455        "--pattern".into(),
456        ASSET.into(),
457        "--output".into(),
458        dest.display().to_string(),
459        "--clobber".into(),
460    ]
461}
462
463/// `gh attestation verify` argv: the provenance check the install docs tell people to run.
464fn gh_verify_args(dest: &Path) -> Vec<String> {
465    vec![
466        "gh".into(),
467        "attestation".into(),
468        "verify".into(),
469        dest.display().to_string(),
470        "--repo".into(),
471        RELEASE_REPO.into(),
472    ]
473}
474
475/// curl argv: fail on HTTP errors, follow the release redirect to the CDN, HTTPS only, and
476/// a ceiling so a stalled transfer is an error rather than a hang.
477fn curl_args(url: &str, dest: &Path) -> Vec<String> {
478    vec![
479        "curl".into(),
480        "-sSfL".into(),
481        "--proto".into(),
482        "=https".into(),
483        "--max-time".into(),
484        "300".into(),
485        "--output".into(),
486        dest.display().to_string(),
487        url.into(),
488    ]
489}
490
491/// Checks `bytes` against `asset`'s entry in a `SHA256SUMS` body (`<64 hex>  <name>` per
492/// line, sha256sum's own format). In-process, because `sha256sum` the tool does not exist
493/// on Windows.
494pub fn verify_sha256(sums: &str, asset: &str, bytes: &[u8]) -> Result<(), String> {
495    let expected = sums
496        .lines()
497        .filter_map(|line| {
498            let mut parts = line.split_whitespace();
499            let digest = parts.next()?;
500            let name = parts.next()?;
501            // sha256sum marks a binary-mode entry with a leading `*`.
502            (name.trim_start_matches('*') == asset).then(|| digest.to_ascii_lowercase())
503        })
504        .next()
505        .ok_or_else(|| {
506            format!(
507                "the release's SHA256SUMS has no entry for {asset}, so nothing to verify against"
508            )
509        })?;
510    let actual = sha256_hex(bytes);
511    if actual == expected {
512        return Ok(());
513    }
514    Err(format!(
515        "SHA256 mismatch for {asset}: the release says {expected}, the download hashed to \
516         {actual}. The bytes were not installed; retry, and if it repeats, treat the \
517         mismatch as the finding rather than the obstacle."
518    ))
519}
520
521/// The lowercase hex SHA-256 of `bytes`.
522pub fn sha256_hex(bytes: &[u8]) -> String {
523    use sha2::{Digest as _, Sha256};
524    const_hex::encode(Sha256::digest(bytes))
525}
526
527/// The `e_machine` field of an ELF header, or `None` if `bytes` does not start with one.
528///
529/// Twenty bytes: the four-byte magic, `EI_DATA` at offset 5 deciding the byte order, then
530/// the two-byte `e_machine` at 18. Reading the byte order rather than assuming little is
531/// not pedantry: a big-endian binary would otherwise report machine `0xB700` and be
532/// rejected with a number nobody can look up.
533pub fn elf_machine(bytes: &[u8]) -> Option<u16> {
534    let header = bytes.get(..20)?;
535    if &header[..4] != b"\x7fELF" {
536        return None;
537    }
538    let field = [header[18], header[19]];
539    Some(if header[5] == 1 {
540        u16::from_le_bytes(field)
541    } else {
542        u16::from_be_bytes(field)
543    })
544}
545
546/// Why `bytes` is not an aarch64 ELF, or `None` when it is.
547pub fn not_aarch64(bytes: &[u8]) -> Option<String> {
548    match elf_machine(bytes) {
549        Some(REQUIRED_ELF_MACHINE) => None,
550        Some(machine) => Some(format!(
551            "ELF machine 0x{machine:x}, not aarch64 (0x{REQUIRED_ELF_MACHINE:x})"
552        )),
553        None => Some("not an ELF binary at all".to_string()),
554    }
555}
556
557/// A version as a release tag spells it, without the leading `v`, or a refusal.
558///
559/// The version becomes a path component of the cache and an argument to `gh` and a URL, so
560/// it must be a plain tag: ASCII letters, digits, `.`, `+`, `-`, starting with a letter or
561/// digit, with no `..`. That rules out every traversal and every flag.
562pub fn normalize_version(version: &str) -> Result<String, ProvisionError> {
563    let bare = version.strip_prefix('v').unwrap_or(version);
564    let plain = !bare.is_empty()
565        && bare.len() <= MAX_VERSION_LEN
566        && bare.as_bytes()[0].is_ascii_alphanumeric()
567        && !bare.contains("..")
568        && bare
569            .bytes()
570            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'+' | b'-'));
571    if plain {
572        return Ok(bare.to_string());
573    }
574    Err(ProvisionError::new(
575        Failure::InvalidVersion,
576        format!(
577            "{version:?} is not a release version: expected a tag such as {} or v{}, made of \
578             letters, digits, `.`, `+`, and `-`",
579            crate::VERSION,
580            crate::VERSION
581        ),
582    )
583    .remedy("omit the version to provision the core's own, which is the one it is proven against"))
584}
585
586/// The cache path for `version`, under `state_dir`.
587///
588/// Versioned by directory rather than by file name, so the binary keeps the name the
589/// Dockerfile stanza and every error message call it.
590pub fn cache_path(state_dir: &Path, version: &str) -> PathBuf {
591    state_dir
592        .join("agentd")
593        .join(format!("v{version}"))
594        .join(ASSET)
595}
596
597/// The digest record beside [`cache_path`].
598fn record_path(state_dir: &Path, version: &str) -> PathBuf {
599    state_dir
600        .join("agentd")
601        .join(format!("v{version}"))
602        .join(RECORD)
603}
604
605/// The record's body: the version, the digest of the bytes verified, and how.
606pub(crate) fn record_json(version: &str, sha256: &str, verification: Verification) -> String {
607    serde_json::json!({
608        "version": version,
609        "sha256": sha256,
610        "verification": verification.as_str(),
611    })
612    .to_string()
613}
614
615/// How the cache entry for `version` was verified, if its record exists, names this
616/// version, and matches `sha256`.
617fn recorded(state_dir: &Path, version: &str, sha256: &str) -> Option<Verification> {
618    let text = std::fs::read_to_string(record_path(state_dir, version)).ok()?;
619    parse_record(&text, version, sha256)
620}
621
622/// How a record body says the entry was verified, if it names `version` and `sha256`
623/// exactly.
624pub(crate) fn parse_record(text: &str, version: &str, sha256: &str) -> Option<Verification> {
625    let record: serde_json::Value = serde_json::from_str(text).ok()?;
626    let matches =
627        record["version"].as_str() == Some(version) && record["sha256"].as_str() == Some(sha256);
628    matches
629        .then(|| Verification::parse(record["verification"].as_str()?))
630        .flatten()
631}
632
633/// A filesystem failure under the state directory.
634fn io_error(path: &Path, verb: &str, error: &std::io::Error) -> ProvisionError {
635    ProvisionError::new(
636        Failure::Io,
637        format!("could not {verb} {}: {error}", path.display()),
638    )
639    .remedy("the failure is on this machine's filesystem; the platform was not involved")
640}
641
642/// A failed fetch, with every way out named: the manual download, the override variable,
643/// and the self-build. This is the error a fresh machine with no `gh` and no network sees,
644/// so it carries the whole story rather than pointing at a doc.
645fn fetch_error(tag: &str, reason: &str) -> ProvisionError {
646    ProvisionError::new(
647        Failure::Fetch,
648        format!("could not provision the agentd daemon binary for {tag}: {reason}"),
649    )
650    .remedy(format!(
651        "manual download: `gh release download {tag} --repo {RELEASE_REPO} --pattern {ASSET}` \
652         (then `gh attestation verify {ASSET} --repo {RELEASE_REPO}`), and pass its path as \
653         the binary or ${ENV_OVERRIDE}"
654    ))
655    .remedy(
656        "a self-built daemon works too: cargo build --release -p agentd --target \
657         aarch64-unknown-linux-musl",
658    )
659}
660
661/// Answers `request` from the caller's binary, the cache, or the release, in that order.
662///
663/// `env` supplies `$MICROVM_AGENTD` and, when [`Request::state_dir`] is `None`, the
664/// variables [`crate::names::default_state_root`] reads. `fetch` is the release seam;
665/// `progress` receives one line per step.
666pub fn resolve(
667    request: &Request<'_>,
668    env: &dyn Fn(&str) -> Option<String>,
669    fetch: &dyn Fetch,
670    progress: &mut dyn FnMut(&str),
671) -> Result<Provisioned, ProvisionError> {
672    let version = normalize_version(request.version.unwrap_or(crate::VERSION))?;
673
674    // The caller's binary first: a caller who named one manages it, and a cache hit that
675    // silently outranked it would run a daemon they did not choose.
676    let supplied = match request.binary {
677        Some(path) => Some((path.to_path_buf(), Supplier::Argument)),
678        None => env(ENV_OVERRIDE).map(|path| (PathBuf::from(path), Supplier::Env)),
679    };
680    if let Some((path, supplier)) = supplied {
681        return caller_supplied(path, supplier, version, progress);
682    }
683
684    let state_dir = match request.state_dir {
685        Some(dir) => dir.to_path_buf(),
686        None => crate::names::default_state_root(env),
687    };
688    let cached = cache_path(&state_dir, &version);
689    if let Some(hit) = cache_hit(&state_dir, &cached, &version, progress)? {
690        return Ok(hit);
691    }
692    fetched(&state_dir, &cached, version, fetch, progress)
693}
694
695fn caller_supplied(
696    path: PathBuf,
697    supplier: Supplier,
698    version: String,
699    progress: &mut dyn FnMut(&str),
700) -> Result<Provisioned, ProvisionError> {
701    let named = match supplier {
702        Supplier::Argument => format!("the agentd binary {}", path.display()),
703        Supplier::Env => format!("${ENV_OVERRIDE} names {}, which", path.display()),
704    };
705    let refuse = |failure: Failure, detail: String| {
706        let error = ProvisionError::new(failure, format!("{named} {detail}"));
707        match supplier {
708            Supplier::Argument => error,
709            Supplier::Env => error
710                .remedy(format!(
711                    "unset {ENV_OVERRIDE} to provision the release asset instead"
712                ))
713                .remedy("or point it at a real aarch64 agentd binary"),
714        }
715    };
716    if !path.exists() {
717        return Err(refuse(
718            Failure::CallerBinary(supplier),
719            "does not exist. A caller-supplied binary short-circuits provisioning, so a \
720             stale path blocks the fetch that would otherwise have worked."
721                .to_string(),
722        ));
723    }
724    let bytes = std::fs::read(&path).map_err(|error| {
725        refuse(
726            Failure::CallerBinary(supplier),
727            format!("could not be read: {error}"),
728        )
729    })?;
730    if let Some(why) = not_aarch64(&bytes) {
731        return Err(refuse(
732            Failure::NotAarch64,
733            format!(
734                "is {why}. MicroVMs are ARM64-only, and a wrong-architecture daemon fails as \
735                 a run-hook timeout 45 minutes into a build."
736            ),
737        )
738        .remedy("build one: cargo build --release -p agentd --target aarch64-unknown-linux-musl"));
739    }
740    match supplier {
741        Supplier::Argument => progress(&format!("using the caller's agentd: {}", path.display())),
742        Supplier::Env => progress(&format!("using ${ENV_OVERRIDE}: {}", path.display())),
743    }
744    Ok(Provisioned {
745        sha256: sha256_hex(&bytes),
746        bytes,
747        path,
748        source: Source::CallerSupplied(supplier),
749        version,
750    })
751}
752
753/// The cache entry for `version`, when its bytes still match its record; otherwise the
754/// entry is discarded and `None` sends the caller to a fetch (BIND-19).
755fn cache_hit(
756    state_dir: &Path,
757    cached: &Path,
758    version: &str,
759    progress: &mut dyn FnMut(&str),
760) -> Result<Option<Provisioned>, ProvisionError> {
761    let bytes = match std::fs::read(cached) {
762        Ok(bytes) => bytes,
763        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
764        Err(error) => return Err(io_error(cached, "read", &error)),
765    };
766    let sha256 = sha256_hex(&bytes);
767    match recorded(state_dir, version, &sha256) {
768        Some(verification) if not_aarch64(&bytes).is_none() => {
769            progress(&format!(
770                "using cached agentd v{version}: {}",
771                cached.display()
772            ));
773            Ok(Some(Provisioned {
774                bytes,
775                path: cached.to_path_buf(),
776                source: Source::Cache(verification),
777                version: version.to_string(),
778                sha256,
779            }))
780        }
781        _ => {
782            progress(&format!(
783                "cached agentd v{version} at {} does not match the digest recorded when it \
784                 was verified; discarding it and fetching the release again",
785                cached.display()
786            ));
787            for path in [cached.to_path_buf(), record_path(state_dir, version)] {
788                match std::fs::remove_file(&path) {
789                    Ok(()) => {}
790                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
791                    Err(error) => return Err(io_error(&path, "discard", &error)),
792                }
793            }
794            Ok(None)
795        }
796    }
797}
798
799fn fetched(
800    state_dir: &Path,
801    cached: &Path,
802    version: String,
803    fetch: &dyn Fetch,
804    progress: &mut dyn FnMut(&str),
805) -> Result<Provisioned, ProvisionError> {
806    let tag = format!("v{version}");
807    progress(&format!(
808        "no agentd given; fetching the release asset for {tag}"
809    ));
810    let dir = cached.parent().expect("the cache path has a parent");
811    std::fs::create_dir_all(dir).map_err(|error| io_error(dir, "create", &error))?;
812    // A partial name in the same directory, so the final `rename` is atomic on the same
813    // filesystem: an interrupted download leaves a `.partial` nothing trusts, never a
814    // truncated `agentd` the next request reads.
815    let partial = dir.join(format!(".{ASSET}.partial-{}", std::process::id()));
816    let verification = match fetch.fetch(&tag, &partial, progress) {
817        Ok(verification) => verification,
818        Err(reason) => {
819            let _ = std::fs::remove_file(&partial);
820            return Err(fetch_error(&tag, &reason));
821        }
822    };
823    let bytes = std::fs::read(&partial).map_err(|error| {
824        let _ = std::fs::remove_file(&partial);
825        io_error(&partial, "read", &error)
826    })?;
827    if let Some(why) = not_aarch64(&bytes) {
828        let _ = std::fs::remove_file(&partial);
829        return Err(ProvisionError::new(
830            Failure::NotAarch64,
831            format!(
832                "the fetched {tag} asset is {why}, so it was discarded rather than cached: a \
833                 wrong-architecture daemon fails as a run-hook timeout 45 minutes into a build."
834            ),
835        )
836        .remedy("this is a release-asset defect worth reporting, not a local mistake"));
837    }
838
839    #[cfg(unix)]
840    {
841        use std::os::unix::fs::PermissionsExt as _;
842        std::fs::set_permissions(&partial, std::fs::Permissions::from_mode(0o755))
843            .map_err(|error| io_error(&partial, "chmod", &error))?;
844    }
845    std::fs::rename(&partial, cached).map_err(|error| io_error(cached, "install", &error))?;
846    // The record after the binary: a crash between the two leaves a binary with no record,
847    // which the next request discards and fetches again rather than trusts.
848    let sha256 = sha256_hex(&bytes);
849    let record = record_path(state_dir, &version);
850    let record_partial = dir.join(format!(".{RECORD}.partial-{}", std::process::id()));
851    std::fs::write(
852        &record_partial,
853        record_json(&version, &sha256, verification),
854    )
855    .and_then(|()| std::fs::rename(&record_partial, &record))
856    .map_err(|error| {
857        let _ = std::fs::remove_file(&record_partial);
858        io_error(&record, "record", &error)
859    })?;
860    progress(&format!(
861        "fetched and verified agentd {tag} ({}); cached at {}",
862        verification.as_str(),
863        cached.display()
864    ));
865    Ok(Provisioned {
866        bytes,
867        path: cached.to_path_buf(),
868        source: Source::Fetched(verification),
869        version,
870        sha256,
871    })
872}
873
874/// The process environment, for the entry points that do not take one.
875fn process_env(name: &str) -> Option<String> {
876    std::env::var(name).ok()
877}
878
879/// **The one call.** The daemon binary for `version` (default: [`crate::VERSION`]) under
880/// `state_dir` (default: the CLI's), from `$MICROVM_AGENTD`, the cache, or a verified fetch
881/// through `gh` or `curl`.
882///
883/// Blocking: a fetch runs subprocesses and can take seconds.
884pub fn agentd(version: Option<&str>, state_dir: Option<&Path>) -> Result<Provisioned, Error> {
885    agentd_with(&Request {
886        version,
887        state_dir,
888        binary: None,
889    })
890}
891
892/// [`agentd`] with a caller-supplied binary as well: the bindings' entry point.
893pub fn agentd_with(request: &Request<'_>) -> Result<Provisioned, Error> {
894    resolve(request, &process_env, &SubprocessFetch, &mut |_| {}).map_err(Error::from)
895}
896
897#[cfg(test)]
898mod tests {
899    use super::*;
900    use std::cell::{Cell, RefCell};
901
902    /// A scripted fetch: writes `bytes` to the destination and counts invocations.
903    struct Scripted {
904        bytes: Vec<u8>,
905        verification: Verification,
906        calls: Cell<usize>,
907    }
908
909    impl Scripted {
910        fn elf() -> Self {
911            Self::with(elf_header(REQUIRED_ELF_MACHINE))
912        }
913
914        fn with(bytes: Vec<u8>) -> Self {
915            Self {
916                bytes,
917                verification: Verification::Attestation,
918                calls: Cell::new(0),
919            }
920        }
921    }
922
923    impl Fetch for Scripted {
924        fn fetch(
925            &self,
926            _: &str,
927            dest: &Path,
928            _: &mut dyn FnMut(&str),
929        ) -> Result<Verification, String> {
930            self.calls.set(self.calls.get() + 1);
931            std::fs::write(dest, &self.bytes).expect("writes");
932            Ok(self.verification)
933        }
934    }
935
936    /// A fetch that always fails, for the error-path assertions.
937    struct Failing;
938
939    impl Fetch for Failing {
940        fn fetch(
941            &self,
942            _: &str,
943            _: &Path,
944            _: &mut dyn FnMut(&str),
945        ) -> Result<Verification, String> {
946            Err("no network in tests".into())
947        }
948    }
949
950    /// A fetch that must not run.
951    struct Panicking;
952
953    impl Fetch for Panicking {
954        fn fetch(
955            &self,
956            tag: &str,
957            _: &Path,
958            _: &mut dyn FnMut(&str),
959        ) -> Result<Verification, String> {
960            panic!("this request must not fetch (asked for {tag})");
961        }
962    }
963
964    /// A 20-byte little-endian ELF header for `machine`.
965    fn elf_header(machine: u16) -> Vec<u8> {
966        let mut header = vec![0u8; 20];
967        header[..4].copy_from_slice(b"\x7fELF");
968        header[5] = 1;
969        header[18..20].copy_from_slice(&machine.to_le_bytes());
970        header
971    }
972
973    fn no_env(_: &str) -> Option<String> {
974        None
975    }
976
977    fn request(dir: &Path) -> Request<'_> {
978        Request {
979            version: Some("9.9.9"),
980            state_dir: Some(dir),
981            binary: None,
982        }
983    }
984
985    /// **A cache miss fetches once; the next request is a cache hit** (BIND-17). The
986    /// property the module exists for: one download per version per machine.
987    #[test]
988    fn a_cache_miss_fetches_once_and_the_next_request_reads_the_cache() {
989        let dir = tempfile::tempdir().expect("a temp dir");
990        let fetch = Scripted::elf();
991        let first = resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("resolves");
992        assert_eq!(first.source, Source::Fetched(Verification::Attestation));
993        assert_eq!(fetch.calls.get(), 1);
994        assert_eq!(first.bytes, std::fs::read(&first.path).expect("reads"));
995        #[cfg(unix)]
996        {
997            use std::os::unix::fs::PermissionsExt as _;
998            let mode = std::fs::metadata(&first.path)
999                .expect("stat")
1000                .permissions()
1001                .mode();
1002            assert_eq!(
1003                mode & 0o111,
1004                0o111,
1005                "the installed binary must be executable"
1006            );
1007        }
1008
1009        let second = resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("resolves");
1010        assert_eq!(second.source, Source::Cache(Verification::Attestation));
1011        assert_eq!(fetch.calls.get(), 1, "a cache hit must not fetch again");
1012        assert_eq!(second.path, first.path);
1013        assert_eq!(second.sha256, first.sha256);
1014    }
1015
1016    /// The version defaults to the core's own, with or without a `v` (BIND-17).
1017    #[test]
1018    fn the_version_defaults_to_the_cores_own_and_accepts_a_leading_v() {
1019        let dir = tempfile::tempdir().expect("a temp dir");
1020        let fetch = Scripted::elf();
1021        let default = Request {
1022            state_dir: Some(dir.path()),
1023            ..Request::default()
1024        };
1025        let served = resolve(&default, &no_env, &fetch, &mut |_| {}).expect("resolves");
1026        assert_eq!(served.version, crate::VERSION);
1027        assert_eq!(served.path, cache_path(dir.path(), crate::VERSION));
1028        let tagged = format!("v{}", crate::VERSION);
1029        let again = Request {
1030            version: Some(&tagged),
1031            ..default
1032        };
1033        let hit = resolve(&again, &no_env, &Panicking, &mut |_| {}).expect("resolves");
1034        assert_eq!(hit.source.as_str(), "cache");
1035    }
1036
1037    /// **The caller's binary outranks the cache and never fetches** (BIND-17), and the
1038    /// argument outranks `$MICROVM_AGENTD`.
1039    #[test]
1040    fn a_caller_binary_outranks_the_environment_and_the_cache() {
1041        let dir = tempfile::tempdir().expect("a temp dir");
1042        resolve(&request(dir.path()), &no_env, &Scripted::elf(), &mut |_| {}).expect("caches");
1043        let own = dir.path().join("my-agentd");
1044        let mut bytes = elf_header(REQUIRED_ELF_MACHINE);
1045        bytes.extend_from_slice(b"caller-managed");
1046        std::fs::write(&own, &bytes).expect("writes");
1047        let env_path = dir.path().join("env-agentd");
1048        std::fs::write(&env_path, elf_header(REQUIRED_ELF_MACHINE)).expect("writes");
1049        let env_str = env_path.display().to_string();
1050        let env = move |name: &str| (name == ENV_OVERRIDE).then(|| env_str.clone());
1051
1052        let from_env = resolve(&request(dir.path()), &env, &Panicking, &mut |_| {}).expect("env");
1053        assert_eq!(from_env.source, Source::CallerSupplied(Supplier::Env));
1054        assert_eq!(from_env.path, env_path);
1055        assert_eq!(from_env.verification(), None);
1056
1057        let with_binary = Request {
1058            binary: Some(&own),
1059            ..request(dir.path())
1060        };
1061        let from_arg = resolve(&with_binary, &env, &Panicking, &mut |_| {}).expect("argument");
1062        assert_eq!(from_arg.source, Source::CallerSupplied(Supplier::Argument));
1063        assert_eq!(from_arg.bytes, bytes);
1064    }
1065
1066    /// A `$MICROVM_AGENTD` pointing at nothing is an error naming the variable, not a
1067    /// fall-through to a fetch the caller opted out of.
1068    #[test]
1069    fn a_stale_environment_override_is_an_error_rather_than_a_fallthrough() {
1070        let dir = tempfile::tempdir().expect("a temp dir");
1071        let env = |name: &str| (name == ENV_OVERRIDE).then(|| "/definitely/not/here".to_string());
1072        let failure = resolve(&request(dir.path()), &env, &Panicking, &mut |_| {})
1073            .expect_err("a stale override must refuse");
1074        assert_eq!(failure.failure, Failure::CallerBinary(Supplier::Env));
1075        assert_eq!(failure.kind(), ErrorKind::Precondition);
1076        assert!(
1077            failure.message.contains(ENV_OVERRIDE),
1078            "{}",
1079            failure.message
1080        );
1081        assert!(failure.remedies.iter().any(|r| r.contains("unset")));
1082    }
1083
1084    /// **A caller-supplied binary that is not an aarch64 ELF is refused** (BIND-20), from
1085    /// either supplier, and never falls through to a fetch.
1086    #[test]
1087    fn a_caller_binary_that_is_not_aarch64_is_refused() {
1088        let dir = tempfile::tempdir().expect("a temp dir");
1089        let x86 = dir.path().join("x86");
1090        std::fs::write(&x86, elf_header(0x3E)).expect("writes");
1091        let script = dir.path().join("script");
1092        std::fs::write(&script, b"#!/bin/sh\n").expect("writes");
1093        for (path, detail) in [(&x86, "ELF machine 0x3e"), (&script, "not an ELF")] {
1094            let with_binary = Request {
1095                binary: Some(path),
1096                ..request(dir.path())
1097            };
1098            let failure =
1099                resolve(&with_binary, &no_env, &Panicking, &mut |_| {}).expect_err("must refuse");
1100            assert_eq!(failure.failure, Failure::NotAarch64);
1101            assert!(failure.message.contains(detail), "{}", failure.message);
1102        }
1103        let x86_str = x86.display().to_string();
1104        let env = move |name: &str| (name == ENV_OVERRIDE).then(|| x86_str.clone());
1105        let failure =
1106            resolve(&request(dir.path()), &env, &Panicking, &mut |_| {}).expect_err("must refuse");
1107        assert!(
1108            failure.message.contains(ENV_OVERRIDE),
1109            "{}",
1110            failure.message
1111        );
1112    }
1113
1114    /// **Fetched bytes that are not an aarch64 ELF are discarded, and the cache stays
1115    /// empty** (BIND-20). Caching them would turn one bad download into a persistent
1116    /// run-hook-timeout mystery on every later request.
1117    #[test]
1118    fn a_fetched_non_arm_binary_is_refused_and_not_cached() {
1119        let dir = tempfile::tempdir().expect("a temp dir");
1120        let failure = resolve(
1121            &request(dir.path()),
1122            &no_env,
1123            &Scripted::with(elf_header(0x3E)),
1124            &mut |_| {},
1125        )
1126        .expect_err("an x86 asset must refuse");
1127        assert!(failure.message.contains("0x3e"), "{}", failure.message);
1128        assert!(
1129            !cache_path(dir.path(), "9.9.9").exists(),
1130            "nothing may be cached"
1131        );
1132
1133        let failure = resolve(
1134            &request(dir.path()),
1135            &no_env,
1136            &Scripted::with(b"#!/bin/sh".to_vec()),
1137            &mut |_| {},
1138        )
1139        .expect_err("a non-ELF asset must refuse");
1140        assert!(
1141            failure.message.contains("not an ELF"),
1142            "{}",
1143            failure.message
1144        );
1145        let left: Vec<_> = std::fs::read_dir(cache_path(dir.path(), "9.9.9").parent().unwrap())
1146            .expect("the version dir exists")
1147            .collect();
1148        assert!(left.is_empty(), "no partial file may survive: {left:?}");
1149    }
1150
1151    /// A failed fetch carries the tag and every way out (BIND-18).
1152    #[test]
1153    fn a_failed_fetch_names_the_tag_and_every_way_out() {
1154        let dir = tempfile::tempdir().expect("a temp dir");
1155        let failure =
1156            resolve(&request(dir.path()), &no_env, &Failing, &mut |_| {}).expect_err("no network");
1157        assert_eq!(failure.failure, Failure::Fetch);
1158        assert!(failure.message.contains("v9.9.9"), "{}", failure.message);
1159        let remedies = failure.remedies.join("\n");
1160        assert!(remedies.contains("gh release download"), "{remedies}");
1161        assert!(remedies.contains(ENV_OVERRIDE), "{remedies}");
1162        assert!(
1163            remedies.contains("cargo build --release -p agentd"),
1164            "{remedies}"
1165        );
1166        // The binding form carries them in its one message.
1167        let error = Error::from(failure);
1168        assert_eq!(error.code(), "ERR_PRECONDITION");
1169        assert!(error.to_string().contains("gh release download"), "{error}");
1170    }
1171
1172    /// **A cache entry that no longer matches its record is fetched again** (BIND-19), and
1173    /// so is one with no record at all.
1174    #[test]
1175    fn a_changed_or_unrecorded_cache_entry_is_fetched_again() {
1176        let dir = tempfile::tempdir().expect("a temp dir");
1177        let fetch = Scripted::elf();
1178        resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("caches");
1179        let cached = cache_path(dir.path(), "9.9.9");
1180        let mut changed = elf_header(REQUIRED_ELF_MACHINE);
1181        changed.extend_from_slice(b"changed");
1182        std::fs::write(&cached, &changed).expect("overwrites");
1183        let mut lines = Vec::new();
1184        let again = resolve(&request(dir.path()), &no_env, &fetch, &mut |line| {
1185            lines.push(line.to_string())
1186        })
1187        .expect("refetches");
1188        assert_eq!(again.source.as_str(), "fetched");
1189        assert_eq!(fetch.calls.get(), 2);
1190        assert_eq!(again.bytes, fetch.bytes);
1191        assert!(
1192            lines.iter().any(|line| line.contains("does not match")),
1193            "{lines:?}"
1194        );
1195
1196        std::fs::remove_file(record_path(dir.path(), "9.9.9")).expect("removes the record");
1197        let unrecorded =
1198            resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("refetches");
1199        assert_eq!(unrecorded.source.as_str(), "fetched");
1200        assert_eq!(fetch.calls.get(), 3);
1201    }
1202
1203    /// A record for another version, or a garbled one, is no record (BIND-17, BIND-19).
1204    #[test]
1205    fn a_record_for_another_version_does_not_vouch_for_this_one() {
1206        let dir = tempfile::tempdir().expect("a temp dir");
1207        let fetch = Scripted::elf();
1208        resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("caches");
1209        let digest = sha256_hex(&fetch.bytes);
1210        let record = record_path(dir.path(), "9.9.9");
1211        std::fs::write(
1212            &record,
1213            record_json("1.0.0", &digest, Verification::Attestation),
1214        )
1215        .expect("writes");
1216        resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("refetches");
1217        assert_eq!(fetch.calls.get(), 2);
1218        std::fs::write(&record, "{not json").expect("writes");
1219        resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("refetches");
1220        assert_eq!(fetch.calls.get(), 3);
1221    }
1222
1223    /// Versions that are not plain tags are refused before anything touches the disk.
1224    #[test]
1225    fn a_version_that_is_not_a_tag_is_refused() {
1226        for bad in [
1227            "", "v", "../x", "1.0/../x", ".hidden", "-rf", "1..2", "a b", "1\n",
1228        ] {
1229            let failure = normalize_version(bad).expect_err(bad);
1230            assert_eq!(failure.kind(), ErrorKind::InvalidArg, "{bad:?}");
1231        }
1232        assert_eq!(normalize_version("v0.9.0").unwrap(), "0.9.0");
1233        assert_eq!(
1234            normalize_version("1.2.3-rc.1+build").unwrap(),
1235            "1.2.3-rc.1+build"
1236        );
1237    }
1238
1239    /// The SHA256SUMS check: a matching entry passes, a mismatch refuses with both digests,
1240    /// and a missing entry refuses rather than passing vacuously.
1241    #[test]
1242    fn the_checksum_verification_matches_mismatches_and_refuses_a_missing_entry() {
1243        let digest = sha256_hex(b"agentd-bytes");
1244        let sums = format!("{digest}  agentd\nother  microvm-x86_64.tar.gz\n");
1245        assert!(verify_sha256(&sums, "agentd", b"agentd-bytes").is_ok());
1246        let starred = format!("{digest} *agentd\n");
1247        assert!(verify_sha256(&starred, "agentd", b"agentd-bytes").is_ok());
1248
1249        let mismatch = verify_sha256(&sums, "agentd", b"tampered").expect_err("must refuse");
1250        assert!(mismatch.contains(&digest), "{mismatch}");
1251        assert!(mismatch.contains("not installed"), "{mismatch}");
1252
1253        let missing =
1254            verify_sha256("abc  something-else\n", "agentd", b"x").expect_err("must refuse");
1255        assert!(missing.contains("no entry"), "{missing}");
1256    }
1257
1258    /// The ELF parser, both byte orders, and everything shorter than a header.
1259    #[test]
1260    fn the_elf_header_is_read_in_its_own_byte_order() {
1261        assert_eq!(elf_machine(&elf_header(0xB7)), Some(0xB7));
1262        let mut big = elf_header(0);
1263        big[5] = 2;
1264        big[18..20].copy_from_slice(&0xB7u16.to_be_bytes());
1265        assert_eq!(elf_machine(&big), Some(0xB7));
1266        assert_eq!(elf_machine(b"#!/bin/sh"), None);
1267        assert_eq!(elf_machine(&elf_header(0xB7)[..19]), None);
1268        assert_eq!(not_aarch64(&elf_header(0xB7)), None);
1269    }
1270
1271    /// The argv builders spell the exact commands the docs teach, so the two cannot drift.
1272    #[test]
1273    fn the_subprocess_argv_matches_the_documented_manual_commands() {
1274        let dest = Path::new("/tmp/agentd");
1275        assert_eq!(
1276            gh_download_args("v0.5.0", dest).join(" "),
1277            "gh release download v0.5.0 --repo laithalsaadoon/microvms-agentd \
1278             --pattern agentd --output /tmp/agentd --clobber"
1279        );
1280        assert_eq!(
1281            gh_verify_args(dest).join(" "),
1282            "gh attestation verify /tmp/agentd --repo laithalsaadoon/microvms-agentd"
1283        );
1284        let curl = curl_args(&asset_url("v0.5.0", ASSET), dest).join(" ");
1285        assert!(
1286            curl.starts_with("curl -sSfL --proto =https --max-time 300"),
1287            "{curl}"
1288        );
1289        assert!(
1290            curl.ends_with(
1291                "https://github.com/laithalsaadoon/microvms-agentd/releases/download/v0.5.0/agentd"
1292            ),
1293            "{curl}"
1294        );
1295    }
1296
1297    /// A [`Runner`] that plays the release from a table: which tools download, whether the
1298    /// attestation passes, and what `SHA256SUMS` says.
1299    struct Table {
1300        gh: bool,
1301        attested: bool,
1302        curl: bool,
1303        /// `None`: no SHA256SUMS on the release; `Some(true)`: matching; `Some(false)`: not.
1304        sums: Option<bool>,
1305        asset: Vec<u8>,
1306        ran: RefCell<Vec<String>>,
1307    }
1308
1309    impl Runner for Table {
1310        fn run(&self, argv: &[String]) -> Result<(), String> {
1311            self.ran.borrow_mut().push(argv[..2].join(" "));
1312            let output = || {
1313                let at = argv.iter().position(|a| a == "--output").expect("--output");
1314                PathBuf::from(&argv[at + 1])
1315            };
1316            match (argv[0].as_str(), argv[1].as_str()) {
1317                ("gh", "release") if self.gh => {
1318                    std::fs::write(output(), &self.asset).map_err(|e| e.to_string())
1319                }
1320                ("gh", "attestation") if self.attested => Ok(()),
1321                ("curl", _) if self.curl => {
1322                    let url = argv.last().expect("a url");
1323                    if url.ends_with("/agentd") {
1324                        return std::fs::write(output(), &self.asset).map_err(|e| e.to_string());
1325                    }
1326                    let digest = match self.sums {
1327                        None => return Err("404".into()),
1328                        Some(true) => sha256_hex(&self.asset),
1329                        Some(false) => sha256_hex(b"other"),
1330                    };
1331                    std::fs::write(output(), format!("{digest}  agentd\n"))
1332                        .map_err(|e| e.to_string())
1333                }
1334                _ => Err(format!("{} refused", argv[0])),
1335            }
1336        }
1337    }
1338
1339    /// **The verification table** — the literal mirror of `the_verification_table` in
1340    /// `model/src/provision.rs` (BIND-18): attestation when `gh` downloads and attests,
1341    /// refusal without `curl` when it downloads and does not, and otherwise `curl` with
1342    /// only a matching `SHA256SUMS` passing. A refused fetch leaves no file behind.
1343    #[test]
1344    fn the_verification_table_matches_the_model() {
1345        // (gh downloads, attested, curl downloads, sums) → verdict; `None` is refused.
1346        type Row = (bool, bool, bool, Option<bool>, Option<Verification>);
1347        let rows: [Row; 10] = [
1348            (
1349                true,
1350                true,
1351                true,
1352                Some(true),
1353                Some(Verification::Attestation),
1354            ),
1355            (true, true, false, None, Some(Verification::Attestation)),
1356            (true, false, true, Some(true), None),
1357            (true, false, false, None, None),
1358            (false, false, true, Some(true), Some(Verification::Checksum)),
1359            (false, false, true, Some(false), None),
1360            (false, false, true, None, None),
1361            (false, false, false, Some(true), None),
1362            (false, true, true, Some(true), Some(Verification::Checksum)),
1363            (false, true, true, None, None),
1364        ];
1365        let dir = tempfile::tempdir().expect("a temp dir");
1366        for (gh, attested, curl, sums, expected) in rows {
1367            let table = Table {
1368                gh,
1369                attested,
1370                curl,
1371                sums,
1372                asset: elf_header(REQUIRED_ELF_MACHINE),
1373                ran: RefCell::new(Vec::new()),
1374            };
1375            let dest = dir.path().join("partial");
1376            let verdict = ReleaseFetch(&table)
1377                .fetch("v9.9.9", &dest, &mut |_| {})
1378                .ok();
1379            let row = (gh, attested, curl, sums);
1380            assert_eq!(verdict, expected, "{row:?}");
1381            if verdict.is_none() {
1382                assert!(!dest.exists(), "a refused fetch left bytes behind: {row:?}");
1383            }
1384            if gh && !attested {
1385                let ran = table.ran.borrow();
1386                assert!(
1387                    ran.iter().all(|tool| !tool.starts_with("curl")),
1388                    "a refused attestation must not fall through to curl: {ran:?}"
1389                );
1390            }
1391            let _ = std::fs::remove_file(&dest);
1392        }
1393    }
1394}