Skip to main content

microvms_edges/
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, or, with
17//!    `$MICROVM_RELEASE_DIR` set, a read of that release's assets from the directory it
18//!    names, proven the same way ([`RELEASE_DIR_VARIABLE`]).
19//!
20//! The version defaults to [`crate::VERSION`], the core's own. The daemon and the client
21//! share one workspace version, so `v{VERSION}` is the one tag whose protocol this client
22//! is proven against; a "latest" fetch would reintroduce exactly the skew the shared
23//! version exists to prevent.
24//!
25//! A refused caller-supplied binary is an error, never a fall-through to the cache or a
26//! fetch: a caller who named a binary manages it, and running a different one would run a
27//! daemon they did not choose.
28//!
29//! # What proves a fetch
30//!
31//! The policy is `microvms_app::provision::fetch_release`, re-exported here as
32//! [`fetch_release`], and the shipped [`Fetch`] is [`HttpsFetch`]: reqwest for the release's
33//! files and `sigstore-verify` for its attestation, both in-process. In order:
34//!
35//! - **Provenance**, from the Sigstore bundle the release workflow published for the asset,
36//!   verified against that workflow's identity at the requested tag. A bundle that doesn't
37//!   verify is a **hard stop**, never a fall-through: bytes that exist but don't verify are the
38//!   one state a fallback must not launder. So is a release that answers it has no bundle for
39//!   the bytes, which is what a replaced asset with its bundle deleted looks like.
40//! - **Integrity**, only when no answer about a bundle can be had at all (a transport failure,
41//!   a rate limit, a server error): the release's `SHA256SUMS` entry,
42//!   hashed in-process. That guards against corruption, which is weaker than provenance, and
43//!   [`Verification`] says which of the two the caller got. A release with no `SHA256SUMS`
44//!   (every tag before v0.5.0) fails closed.
45//!
46//! A fetch that can't be verified is an error (BIND-18), never a warning.
47//!
48//! Nothing here spawns a tool. Until #284 this fetch ran `gh release download` and
49//! `gh attestation verify`, then `curl` and `SHA256SUMS` when `gh` couldn't download, so a
50//! machine without a `gh` login never got an attestation check.
51//!
52//! # What is checked no matter where the bytes came from
53//!
54//! Every binary this module returns is an aarch64 ELF (BIND-20): MicroVMs are ARM64-only,
55//! and a wrong binary baked into an image fails 45 minutes later as a run-hook timeout that
56//! says nothing about architecture. Twenty bytes of header now is the whole cost of never
57//! finding out.
58//!
59//! # The cache serves only what it verified
60//!
61//! An install is write-to-partial-then-rename, so an interrupted download never leaves a
62//! truncated binary where the next request reads. Beside each entry sits a digest record
63//! written after verification, and a cache hit is served only when the bytes still hash to
64//! it (BIND-19). An entry that does not match, or that has no record (one an older client
65//! installed), is ignored and fetched again. The record lives in the same directory as
66//! the binary, so this catches corruption and a binary copied over the entry, not a writer
67//! who replaces both; such a writer could replace a caller-supplied binary just as well.
68//!
69//! `crates/model/src/provision.rs` checks this policy against the ways it could be written
70//! instead, and `crates/microvms-core/tests/features/provision.feature` drives it through a fake
71//! release.
72//!
73//! # The seams
74//!
75//! [`Fetch`] is the download-and-prove seam the CLI's guards script. Under it, the app's
76//! [`ReleaseSource`] and [`AttestationVerifier`] are the seams [`PolicyFetch`] runs the policy
77//! over: [`HttpsFetch`] is that over GitHub and the public-good Sigstore root, the edges tests
78//! answer them with a committed release, and the Gherkin runner with a fake one. None of them
79//! lets a test open a socket to GitHub.
80
81use std::fmt;
82use std::io::Write as _;
83use std::path::{Path, PathBuf};
84
85// The ELF checks read bytes, so they're rules with no I/O; this module is where a caller has
86// always found them.
87pub use microvms_domain::provision::{REQUIRED_ELF_MACHINE, elf_machine, not_aarch64};
88
89use microvms_app::error::{Error, ErrorKind};
90
91// The verification policy and its ports are the app's (#284); this module has always been
92// where a caller found them.
93pub use microvms_app::provision::{
94    ASSET, AttestationVerifier, Bundles, Fetched, RELEASE_REPO, ReleaseSource, Signer,
95    Verification, fetch_release, sha256_hex, verify_sha256,
96};
97
98mod release;
99pub use release::{
100    BUNDLE_ASSET, GitHubRelease, HttpsFetch, PolicyFetch, RELEASE_DIR_VARIABLE, SigstoreVerifier,
101    TOKEN_VARIABLE,
102};
103
104/// The environment variable naming a caller-managed binary, used when a request carries
105/// no [`Request::binary`].
106pub const ENV_OVERRIDE: &str = "MICROVM_AGENTD";
107
108/// The digest record's file name, beside the cached binary.
109const RECORD: &str = "agentd.verified.json";
110
111/// The longest version string accepted. Release tags are short; a bound keeps a hostile
112/// value from becoming a long path component.
113const MAX_VERSION_LEN: usize = 64;
114
115/// How a caller supplied a binary.
116#[derive(Clone, Copy, Debug, PartialEq, Eq)]
117pub enum Supplier {
118    /// [`Request::binary`].
119    Argument,
120    /// `$MICROVM_AGENTD`.
121    Env,
122}
123
124impl Supplier {
125    pub fn as_str(self) -> &'static str {
126        match self {
127            Supplier::Argument => "argument",
128            Supplier::Env => "env",
129        }
130    }
131}
132
133/// Where a provisioned binary came from.
134#[derive(Clone, Copy, Debug, PartialEq, Eq)]
135pub enum Source {
136    /// The caller's own binary. Its provenance is the caller's business, so it carries no
137    /// [`Verification`]; it is checked for aarch64 like everything else.
138    CallerSupplied(Supplier),
139    /// The cache entry for the requested version, whose bytes still match the digest
140    /// recorded when this verification passed.
141    Cache(Verification),
142    /// Fetched from the GitHub release during this call.
143    Fetched(Verification),
144}
145
146impl Source {
147    /// `"caller-supplied"`, `"cache"`, or `"fetched"`.
148    pub fn as_str(self) -> &'static str {
149        match self {
150            Source::CallerSupplied(_) => "caller-supplied",
151            Source::Cache(_) => "cache",
152            Source::Fetched(_) => "fetched",
153        }
154    }
155
156    /// How the bytes were proven: when fetched, or when the cache entry was installed.
157    /// `None` for a caller-supplied binary.
158    pub fn verification(self) -> Option<Verification> {
159        match self {
160            Source::CallerSupplied(_) => None,
161            Source::Cache(verification) | Source::Fetched(verification) => Some(verification),
162        }
163    }
164}
165
166/// A provisioned daemon binary and how it got here.
167#[derive(Clone, PartialEq, Eq)]
168pub struct Provisioned {
169    /// The binary itself, an aarch64 ELF.
170    pub bytes: Vec<u8>,
171    /// Where it is on disk: the cache entry, or the caller's path.
172    pub path: PathBuf,
173    pub source: Source,
174    /// The version requested, without a leading `v`. For a caller-supplied binary, the
175    /// version asked for, which nothing checks the binary against.
176    pub version: String,
177    /// The lowercase hex SHA-256 of [`Self::bytes`].
178    pub sha256: String,
179}
180
181impl Provisioned {
182    /// How the bytes were proven, or `None` for a caller-supplied binary.
183    pub fn verification(&self) -> Option<Verification> {
184        self.source.verification()
185    }
186}
187
188/// Written by hand so a debug print never dumps megabytes of binary.
189impl fmt::Debug for Provisioned {
190    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
191        f.debug_struct("Provisioned")
192            .field("bytes", &format_args!("<{} bytes>", self.bytes.len()))
193            .field("path", &self.path)
194            .field("source", &self.source)
195            .field("version", &self.version)
196            .field("sha256", &self.sha256)
197            .finish()
198    }
199}
200
201/// What a caller asks for. Every field is optional; [`Request::default`] asks for the
202/// core's own version under the default state directory.
203#[derive(Clone, Copy, Debug, Default)]
204pub struct Request<'a> {
205    /// The release version, with or without a leading `v`. Defaults to [`crate::VERSION`].
206    pub version: Option<&'a str>,
207    /// The state directory the cache lives under. Defaults to the CLI's
208    /// ([`crate::names::state_root`]), so every surface shares one cache.
209    pub state_dir: Option<&'a Path>,
210    /// A binary the caller manages. Outranks `$MICROVM_AGENTD`.
211    pub binary: Option<&'a Path>,
212}
213
214/// Which step refused, so a caller can attach its own remedies.
215#[derive(Clone, Copy, Debug, PartialEq, Eq)]
216pub enum Failure {
217    /// The version is not a release tag.
218    InvalidVersion,
219    /// The caller's path names nothing, or cannot be read.
220    CallerBinary(Supplier),
221    /// The binary is not an aarch64 ELF.
222    NotAarch64,
223    /// The release could not be downloaded, or its bytes could not be verified.
224    Fetch,
225    /// A filesystem operation under the state directory failed.
226    Io,
227}
228
229/// A refusal, with the remedies a person or agent can act on.
230#[derive(Clone, Debug, PartialEq, Eq)]
231pub struct ProvisionError {
232    pub failure: Failure,
233    pub message: String,
234    pub remedies: Vec<String>,
235}
236
237impl ProvisionError {
238    fn new(failure: Failure, message: impl Into<String>) -> Self {
239        Self {
240            failure,
241            message: message.into(),
242            remedies: Vec::new(),
243        }
244    }
245
246    fn remedy(mut self, remedy: impl Into<String>) -> Self {
247        self.remedies.push(remedy.into());
248        self
249    }
250
251    /// The error-taxonomy row: `ERR_INVALID_ARG` for a version that is not a tag,
252    /// `ERR_PRECONDITION` for everything else, since no AWS call has been spent.
253    pub fn kind(&self) -> ErrorKind {
254        match self.failure {
255            Failure::InvalidVersion => ErrorKind::InvalidArg,
256            _ => ErrorKind::Precondition,
257        }
258    }
259}
260
261impl fmt::Display for ProvisionError {
262    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
263        f.write_str(&self.message)
264    }
265}
266
267impl std::error::Error for ProvisionError {}
268
269/// The taxonomy error a binding raises: the message, then each remedy, since a binding's
270/// exception has nowhere else to carry them.
271impl From<ProvisionError> for Error {
272    fn from(error: ProvisionError) -> Self {
273        let mut message = error.message.clone();
274        for remedy in &error.remedies {
275            message.push_str("\n  - ");
276            message.push_str(remedy);
277        }
278        Error::new(error.kind(), message)
279    }
280}
281
282/// The download-and-prove seam. The shipped client carries [`HttpsFetch`]; the CLI's
283/// guards script this, which keeps `cargo test` off the network the way its `CoreSeam`
284/// keeps it off AWS.
285pub trait Fetch {
286    /// Download release `tag`'s daemon asset to `dest` and prove the bytes, reporting
287    /// progress lines as it goes. The error is prose: the caller owns the remedies.
288    fn fetch(
289        &self,
290        tag: &str,
291        dest: &Path,
292        progress: &mut dyn FnMut(&str),
293    ) -> Result<Verification, String>;
294
295    /// Return the verified bytes. `dest` is a private temporary file for legacy
296    /// implementations of [`Self::fetch`]; production fetches return their verified
297    /// in-memory download without reading it back from disk.
298    fn fetch_bytes(
299        &self,
300        tag: &str,
301        dest: &Path,
302        progress: &mut dyn FnMut(&str),
303    ) -> Result<Fetched, String> {
304        let verification = self.fetch(tag, dest, progress)?;
305        let bytes = std::fs::read(dest)
306            .map_err(|error| format!("could not read {}: {error}", dest.display()))?;
307        Ok(Fetched {
308            bytes,
309            verification,
310        })
311    }
312}
313
314/// A version as a release tag spells it, without the leading `v`, or a refusal.
315///
316/// The version becomes a path component of the cache and part of a URL, so it must be a
317/// plain tag: ASCII letters, digits, `.`, `+`, `-`, starting with a letter or digit, with no
318/// `..`. That rules out every traversal and every flag.
319pub fn normalize_version(version: &str) -> Result<String, ProvisionError> {
320    let bare = version.strip_prefix('v').unwrap_or(version);
321    let plain = !bare.is_empty()
322        && bare.len() <= MAX_VERSION_LEN
323        && bare.as_bytes()[0].is_ascii_alphanumeric()
324        && !bare.contains("..")
325        && bare
326            .bytes()
327            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'+' | b'-'));
328    if plain {
329        return Ok(bare.to_string());
330    }
331    Err(ProvisionError::new(
332        Failure::InvalidVersion,
333        format!(
334            "{version:?} is not a release version: expected a tag such as {} or v{}, made of \
335             letters, digits, `.`, `+`, and `-`",
336            crate::VERSION,
337            crate::VERSION
338        ),
339    )
340    .remedy("omit the version to provision the core's own, which is the one it is proven against"))
341}
342
343/// The cache path for `version`, under `state_dir`.
344///
345/// Versioned by directory rather than by file name, so the binary keeps the name the
346/// Dockerfile stanza and every error message call it.
347pub fn cache_path(state_dir: &Path, version: &str) -> PathBuf {
348    state_dir
349        .join("agentd")
350        .join(format!("v{version}"))
351        .join(ASSET)
352}
353
354/// The digest record beside [`cache_path`].
355fn record_path(state_dir: &Path, version: &str) -> PathBuf {
356    state_dir
357        .join("agentd")
358        .join(format!("v{version}"))
359        .join(RECORD)
360}
361
362/// The record's body: the version, the digest of the bytes verified, and how.
363pub(crate) fn record_json(version: &str, sha256: &str, verification: Verification) -> String {
364    serde_json::json!({
365        "version": version,
366        "sha256": sha256,
367        "verification": verification.as_str(),
368    })
369    .to_string()
370}
371
372/// How the cache entry for `version` was verified, if its record exists, names this
373/// version, and matches `sha256`.
374fn recorded(state_dir: &Path, version: &str, sha256: &str) -> Option<Verification> {
375    let text = std::fs::read_to_string(record_path(state_dir, version)).ok()?;
376    parse_record(&text, version, sha256)
377}
378
379/// How a record body says the entry was verified, if it names `version` and `sha256`
380/// exactly.
381pub(crate) fn parse_record(text: &str, version: &str, sha256: &str) -> Option<Verification> {
382    let record: serde_json::Value = serde_json::from_str(text).ok()?;
383    let matches =
384        record["version"].as_str() == Some(version) && record["sha256"].as_str() == Some(sha256);
385    let written = record["verification"].as_str()?;
386    [Verification::Attestation, Verification::Checksum]
387        .into_iter()
388        .find(|verification| matches && verification.as_str() == written)
389}
390
391/// A filesystem failure under the state directory.
392fn io_error(path: &Path, verb: &str, error: &std::io::Error) -> ProvisionError {
393    ProvisionError::new(
394        Failure::Io,
395        format!("could not {verb} {}: {error}", path.display()),
396    )
397    .remedy("the failure is on this machine's filesystem; the platform was not involved")
398}
399
400/// A failed fetch, with every way out named: the manual download, the override variable,
401/// and the self-build. This is the error a fresh machine with no `gh` and no network sees,
402/// so it carries the whole story rather than pointing at a doc.
403fn fetch_error(tag: &str, reason: &str) -> ProvisionError {
404    ProvisionError::new(
405        Failure::Fetch,
406        format!("could not provision the agentd daemon binary for {tag}: {reason}"),
407    )
408    .remedy(format!(
409        "manual download: `gh release download {tag} --repo {RELEASE_REPO} --pattern {ASSET}` \
410         (then `gh attestation verify {ASSET} --repo {RELEASE_REPO} \
411         --cert-identity https://github.com/{RELEASE_REPO}/.github/workflows/release.yml@refs/tags/{tag}`), and pass its path as \
412         the binary or ${ENV_OVERRIDE}"
413    ))
414    .remedy(
415        "a self-built daemon works too: cargo build --release -p agentd --target \
416         aarch64-unknown-linux-musl",
417    )
418}
419
420/// Answers `request` from the caller's binary, the cache, or the release, in that order.
421///
422/// `env` supplies `$MICROVM_AGENTD` and, when [`Request::state_dir`] is `None`, the
423/// variables [`crate::names::state_root`] reads. `fetch` is the release seam;
424/// `progress` receives one line per step.
425pub fn resolve(
426    request: &Request<'_>,
427    env: &dyn Fn(&str) -> Option<String>,
428    fetch: &dyn Fetch,
429    progress: &mut dyn FnMut(&str),
430) -> Result<Provisioned, ProvisionError> {
431    let version = normalize_version(request.version.unwrap_or(crate::VERSION))?;
432
433    // The caller's binary first: a caller who named one manages it, and a cache hit that
434    // silently outranked it would run a daemon they did not choose.
435    let supplied = match request.binary {
436        Some(path) => Some((path.to_path_buf(), Supplier::Argument)),
437        None => env(ENV_OVERRIDE).map(|path| (PathBuf::from(path), Supplier::Env)),
438    };
439    if let Some((path, supplier)) = supplied {
440        return caller_supplied(path, supplier, version, progress);
441    }
442
443    let state_dir = match request.state_dir {
444        Some(dir) => dir.to_path_buf(),
445        None => crate::names::state_root(env)
446            .map_err(|error| ProvisionError::new(Failure::Io, error.to_string()))?,
447    };
448    let cached = cache_path(&state_dir, &version);
449    if let Some(hit) = cache_hit(&state_dir, &cached, &version, progress)? {
450        return Ok(hit);
451    }
452    fetched(&state_dir, &cached, version, fetch, progress)
453}
454
455fn caller_supplied(
456    path: PathBuf,
457    supplier: Supplier,
458    version: String,
459    progress: &mut dyn FnMut(&str),
460) -> Result<Provisioned, ProvisionError> {
461    let named = match supplier {
462        Supplier::Argument => format!("the agentd binary {}", path.display()),
463        Supplier::Env => format!("${ENV_OVERRIDE} names {}, which", path.display()),
464    };
465    let refuse = |failure: Failure, detail: String| {
466        let error = ProvisionError::new(failure, format!("{named} {detail}"));
467        match supplier {
468            Supplier::Argument => error,
469            Supplier::Env => error
470                .remedy(format!(
471                    "unset {ENV_OVERRIDE} to provision the release asset instead"
472                ))
473                .remedy("or point it at a real aarch64 agentd binary"),
474        }
475    };
476    if !path.exists() {
477        return Err(refuse(
478            Failure::CallerBinary(supplier),
479            "does not exist. A caller-supplied binary short-circuits provisioning, so a \
480             stale path blocks the fetch that would otherwise have worked."
481                .to_string(),
482        ));
483    }
484    let bytes = std::fs::read(&path).map_err(|error| {
485        refuse(
486            Failure::CallerBinary(supplier),
487            format!("could not be read: {error}"),
488        )
489    })?;
490    if let Some(why) = not_aarch64(&bytes) {
491        return Err(refuse(
492            Failure::NotAarch64,
493            format!(
494                "is {why}. MicroVMs are ARM64-only, and a wrong-architecture daemon fails as \
495                 a run-hook timeout 45 minutes into a build."
496            ),
497        )
498        .remedy("build one: cargo build --release -p agentd --target aarch64-unknown-linux-musl"));
499    }
500    match supplier {
501        Supplier::Argument => progress(&format!("using the caller's agentd: {}", path.display())),
502        Supplier::Env => progress(&format!("using ${ENV_OVERRIDE}: {}", path.display())),
503    }
504    Ok(Provisioned {
505        sha256: sha256_hex(&bytes),
506        bytes,
507        path,
508        source: Source::CallerSupplied(supplier),
509        version,
510    })
511}
512
513/// The cache entry for `version`, when its bytes still match its record; otherwise the
514/// entry is ignored and `None` sends the caller to a fetch (BIND-19).
515fn cache_hit(
516    state_dir: &Path,
517    cached: &Path,
518    version: &str,
519    progress: &mut dyn FnMut(&str),
520) -> Result<Option<Provisioned>, ProvisionError> {
521    let bytes = match std::fs::read(cached) {
522        Ok(bytes) => bytes,
523        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
524        Err(error) => return Err(io_error(cached, "read", &error)),
525    };
526    let sha256 = sha256_hex(&bytes);
527    match recorded(state_dir, version, &sha256) {
528        Some(verification) if not_aarch64(&bytes).is_none() => {
529            progress(&format!(
530                "using cached agentd v{version}: {}",
531                cached.display()
532            ));
533            Ok(Some(Provisioned {
534                bytes,
535                path: cached.to_path_buf(),
536                source: Source::Cache(verification),
537                version: version.to_string(),
538                sha256,
539            }))
540        }
541        _ => {
542            progress(&format!(
543                "cached agentd v{version} at {} does not match the digest recorded when it \
544                 was verified; ignoring it and fetching the release again",
545                cached.display()
546            ));
547            // Another caller may have installed the binary and not its record yet.
548            // Never unlink either path here: only this call's verified replacement
549            // may change the cache, so a concurrent successful install stays usable.
550            Ok(None)
551        }
552    }
553}
554
555fn fetched(
556    state_dir: &Path,
557    cached: &Path,
558    version: String,
559    fetch: &dyn Fetch,
560    progress: &mut dyn FnMut(&str),
561) -> Result<Provisioned, ProvisionError> {
562    let tag = format!("v{version}");
563    progress(&format!(
564        "no agentd given; fetching the release asset for {tag}"
565    ));
566    let dir = cached.parent().expect("the cache path has a parent");
567    std::fs::create_dir_all(dir).map_err(|error| io_error(dir, "create", &error))?;
568    // Every call owns its temporary files. A PID-only name lets a second thread
569    // truncate a verified download before the first thread reads or installs it.
570    // Keep the path guard but close its handle: a legacy fetch may replace the
571    // destination, which Windows refuses while this file is still open.
572    let download = tempfile::NamedTempFile::new_in(dir)
573        .map_err(|error| io_error(dir, "create temporary download in", &error))?
574        .into_temp_path();
575    let Fetched {
576        bytes,
577        verification,
578    } = fetch
579        .fetch_bytes(&tag, &download, progress)
580        .map_err(|reason| fetch_error(&tag, &reason))?;
581    if let Some(why) = not_aarch64(&bytes) {
582        return Err(ProvisionError::new(
583            Failure::NotAarch64,
584            format!(
585                "the fetched {tag} asset is {why}, so it was discarded rather than cached: a \
586                 wrong-architecture daemon fails as a run-hook timeout 45 minutes into a build."
587            ),
588        )
589        .remedy("this is a release-asset defect worth reporting, not a local mistake"));
590    }
591
592    // A legacy Fetch may atomically replace its destination. Use a fresh install
593    // handle so its bytes and permissions always belong to the file we persist.
594    drop(download);
595    let mut partial = tempfile::NamedTempFile::new_in(dir)
596        .map_err(|error| io_error(dir, "create temporary binary in", &error))?;
597    partial
598        .as_file_mut()
599        .set_len(0)
600        .and_then(|()| partial.write_all(&bytes))
601        .map_err(|error| io_error(partial.path(), "write", &error))?;
602    #[cfg(unix)]
603    {
604        use std::os::unix::fs::PermissionsExt as _;
605        partial
606            .as_file()
607            .set_permissions(std::fs::Permissions::from_mode(0o755))
608            .map_err(|error| io_error(partial.path(), "chmod", &error))?;
609    }
610    partial
611        .into_temp_path()
612        .persist(cached)
613        .map_err(|error| io_error(cached, "install", &error.error))?;
614    // The record after the binary: a crash between the two leaves a binary with no record,
615    // which the next request ignores and fetches again rather than trusts.
616    let sha256 = sha256_hex(&bytes);
617    let record = record_path(state_dir, &version);
618    let mut record_partial = tempfile::NamedTempFile::new_in(dir)
619        .map_err(|error| io_error(dir, "create temporary record in", &error))?;
620    record_partial
621        .write_all(record_json(&version, &sha256, verification).as_bytes())
622        .map_err(|error| io_error(&record, "write record", &error))?;
623    record_partial
624        .into_temp_path()
625        .persist(&record)
626        .map_err(|error| io_error(&record, "record", &error.error))?;
627    progress(&format!(
628        "fetched and verified agentd {tag} ({}); cached at {}",
629        verification.as_str(),
630        cached.display()
631    ));
632    Ok(Provisioned {
633        bytes,
634        path: cached.to_path_buf(),
635        source: Source::Fetched(verification),
636        version,
637        sha256,
638    })
639}
640
641/// **The one call.** The daemon binary for `version` (default: [`crate::VERSION`]) under
642/// `state_dir` (default: the CLI's), from `$MICROVM_AGENTD`, the cache, or a verified fetch
643/// over HTTPS ([`HttpsFetch`], with `$GITHUB_TOKEN` when it's set).
644///
645/// Blocking: a fetch downloads a few MiB and can take seconds.
646pub fn agentd(version: Option<&str>, state_dir: Option<&Path>) -> Result<Provisioned, Error> {
647    agentd_with(&Request {
648        version,
649        state_dir,
650        binary: None,
651    })
652}
653
654/// [`agentd`] with a caller-supplied binary as well: the bindings' entry point.
655pub fn agentd_with(request: &Request<'_>) -> Result<Provisioned, Error> {
656    let env = &crate::env::process;
657    resolve(request, env, &HttpsFetch::from_env(env), &mut |_| {}).map_err(Error::from)
658}
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663    use std::cell::Cell;
664
665    /// A scripted fetch: writes `bytes` to the destination and counts invocations.
666    struct Scripted {
667        bytes: Vec<u8>,
668        verification: Verification,
669        calls: Cell<usize>,
670    }
671
672    impl Scripted {
673        fn elf() -> Self {
674            Self::with(elf_header(REQUIRED_ELF_MACHINE))
675        }
676
677        fn with(bytes: Vec<u8>) -> Self {
678            Self {
679                bytes,
680                verification: Verification::Attestation,
681                calls: Cell::new(0),
682            }
683        }
684    }
685
686    impl Fetch for Scripted {
687        fn fetch(
688            &self,
689            _: &str,
690            dest: &Path,
691            _: &mut dyn FnMut(&str),
692        ) -> Result<Verification, String> {
693            self.calls.set(self.calls.get() + 1);
694            std::fs::write(dest, &self.bytes).expect("writes");
695            Ok(self.verification)
696        }
697    }
698
699    /// A fetch that always fails, for the error-path assertions.
700    struct Failing;
701
702    impl Fetch for Failing {
703        fn fetch(
704            &self,
705            _: &str,
706            _: &Path,
707            _: &mut dyn FnMut(&str),
708        ) -> Result<Verification, String> {
709            Err("no network in tests".into())
710        }
711    }
712
713    /// A fetch that must not run.
714    struct Panicking;
715
716    impl Fetch for Panicking {
717        fn fetch(
718            &self,
719            tag: &str,
720            _: &Path,
721            _: &mut dyn FnMut(&str),
722        ) -> Result<Verification, String> {
723            panic!("this request must not fetch (asked for {tag})");
724        }
725    }
726
727    /// A 20-byte little-endian ELF header for `machine`.
728    fn elf_header(machine: u16) -> Vec<u8> {
729        let mut header = vec![0u8; 20];
730        header[..4].copy_from_slice(b"\x7fELF");
731        header[5] = 1;
732        header[18..20].copy_from_slice(&machine.to_le_bytes());
733        header
734    }
735
736    fn no_env(_: &str) -> Option<String> {
737        None
738    }
739
740    fn request(dir: &Path) -> Request<'_> {
741        Request {
742            version: Some("9.9.9"),
743            state_dir: Some(dir),
744            binary: None,
745        }
746    }
747
748    #[test]
749    fn provisioning_requires_an_absolute_default_root() {
750        const CHILD: &str = "MICROVMS_TEST_STATE_ROOT_CHILD";
751        if std::env::var_os(CHILD).is_some() {
752            struct Unused;
753            impl Fetch for Unused {
754                fn fetch(
755                    &self,
756                    _: &str,
757                    _: &Path,
758                    _: &mut dyn FnMut(&str),
759                ) -> Result<Verification, String> {
760                    panic!("an unresolved default root must not start a fetch");
761                }
762            }
763            let request = Request {
764                binary: None,
765                state_dir: None,
766                version: None,
767            };
768            let error = resolve(
769                &request,
770                &|key| matches!(key, "HOME" | "USERPROFILE").then(|| "relative-home".to_string()),
771                &Unused,
772                &mut |_| {},
773            )
774            .expect_err("relative default root");
775            assert!(error.message.contains("absolute user home"), "{error}");
776            assert!(!Path::new("relative-home").exists());
777            return;
778        }
779        // A subprocess gives even the regression's filesystem writes a scratch-only cwd,
780        // without changing the working directory of parallel tests.
781        let directory = tempfile::tempdir().expect("scratch directory");
782        let output = std::process::Command::new(std::env::current_exe().expect("test binary"))
783            .args([
784                "--exact",
785                "provision::tests::provisioning_requires_an_absolute_default_root",
786            ])
787            .env(CHILD, "1")
788            .current_dir(directory.path())
789            .output()
790            .expect("child test");
791        assert!(
792            output.status.success(),
793            "{}",
794            String::from_utf8_lossy(&output.stdout)
795        );
796    }
797
798    /// **A cache miss fetches once; the next request is a cache hit** (BIND-17). The
799    /// property the module exists for: one download per version per machine.
800    #[test]
801    fn a_cache_miss_fetches_once_and_the_next_request_reads_the_cache() {
802        let dir = tempfile::tempdir().expect("a temp dir");
803        let fetch = Scripted::elf();
804        let first = resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("resolves");
805        assert_eq!(first.source, Source::Fetched(Verification::Attestation));
806        assert_eq!(fetch.calls.get(), 1);
807        assert_eq!(first.bytes, std::fs::read(&first.path).expect("reads"));
808        #[cfg(unix)]
809        {
810            use std::os::unix::fs::PermissionsExt as _;
811            let mode = std::fs::metadata(&first.path)
812                .expect("stat")
813                .permissions()
814                .mode();
815            assert_eq!(
816                mode & 0o111,
817                0o111,
818                "the installed binary must be executable"
819            );
820        }
821
822        let second = resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("resolves");
823        assert_eq!(second.source, Source::Cache(Verification::Attestation));
824        assert_eq!(fetch.calls.get(), 1, "a cache hit must not fetch again");
825        assert_eq!(second.path, first.path);
826        assert_eq!(second.sha256, first.sha256);
827    }
828
829    /// The version defaults to the core's own, with or without a `v` (BIND-17).
830    #[test]
831    fn the_version_defaults_to_the_cores_own_and_accepts_a_leading_v() {
832        let dir = tempfile::tempdir().expect("a temp dir");
833        let fetch = Scripted::elf();
834        let default = Request {
835            state_dir: Some(dir.path()),
836            ..Request::default()
837        };
838        let served = resolve(&default, &no_env, &fetch, &mut |_| {}).expect("resolves");
839        assert_eq!(served.version, crate::VERSION);
840        assert_eq!(served.path, cache_path(dir.path(), crate::VERSION));
841        let tagged = format!("v{}", crate::VERSION);
842        let again = Request {
843            version: Some(&tagged),
844            ..default
845        };
846        let hit = resolve(&again, &no_env, &Panicking, &mut |_| {}).expect("resolves");
847        assert_eq!(hit.source.as_str(), "cache");
848    }
849
850    /// **The caller's binary outranks the cache and never fetches** (BIND-17), and the
851    /// argument outranks `$MICROVM_AGENTD`.
852    #[test]
853    fn a_caller_binary_outranks_the_environment_and_the_cache() {
854        let dir = tempfile::tempdir().expect("a temp dir");
855        resolve(&request(dir.path()), &no_env, &Scripted::elf(), &mut |_| {}).expect("caches");
856        let own = dir.path().join("my-agentd");
857        let mut bytes = elf_header(REQUIRED_ELF_MACHINE);
858        bytes.extend_from_slice(b"caller-managed");
859        std::fs::write(&own, &bytes).expect("writes");
860        let env_path = dir.path().join("env-agentd");
861        std::fs::write(&env_path, elf_header(REQUIRED_ELF_MACHINE)).expect("writes");
862        let env_str = env_path.display().to_string();
863        let env = move |name: &str| (name == ENV_OVERRIDE).then(|| env_str.clone());
864
865        let from_env = resolve(&request(dir.path()), &env, &Panicking, &mut |_| {}).expect("env");
866        assert_eq!(from_env.source, Source::CallerSupplied(Supplier::Env));
867        assert_eq!(from_env.path, env_path);
868        assert_eq!(from_env.verification(), None);
869
870        let with_binary = Request {
871            binary: Some(&own),
872            ..request(dir.path())
873        };
874        let from_arg = resolve(&with_binary, &env, &Panicking, &mut |_| {}).expect("argument");
875        assert_eq!(from_arg.source, Source::CallerSupplied(Supplier::Argument));
876        assert_eq!(from_arg.bytes, bytes);
877    }
878
879    /// A `$MICROVM_AGENTD` pointing at nothing is an error naming the variable, not a
880    /// fall-through to a fetch the caller opted out of.
881    #[test]
882    fn a_stale_environment_override_is_an_error_rather_than_a_fallthrough() {
883        let dir = tempfile::tempdir().expect("a temp dir");
884        let env = |name: &str| (name == ENV_OVERRIDE).then(|| "/definitely/not/here".to_string());
885        let failure = resolve(&request(dir.path()), &env, &Panicking, &mut |_| {})
886            .expect_err("a stale override must refuse");
887        assert_eq!(failure.failure, Failure::CallerBinary(Supplier::Env));
888        assert_eq!(failure.kind(), ErrorKind::Precondition);
889        assert!(
890            failure.message.contains(ENV_OVERRIDE),
891            "{}",
892            failure.message
893        );
894        assert!(failure.remedies.iter().any(|r| r.contains("unset")));
895    }
896
897    /// **A caller-supplied binary that is not an aarch64 ELF is refused** (BIND-20), from
898    /// either supplier, and never falls through to a fetch.
899    #[test]
900    fn a_caller_binary_that_is_not_aarch64_is_refused() {
901        let dir = tempfile::tempdir().expect("a temp dir");
902        let x86 = dir.path().join("x86");
903        std::fs::write(&x86, elf_header(0x3E)).expect("writes");
904        let script = dir.path().join("script");
905        std::fs::write(&script, b"#!/bin/sh\n").expect("writes");
906        for (path, detail) in [(&x86, "ELF machine 0x3e"), (&script, "not an ELF")] {
907            let with_binary = Request {
908                binary: Some(path),
909                ..request(dir.path())
910            };
911            let failure =
912                resolve(&with_binary, &no_env, &Panicking, &mut |_| {}).expect_err("must refuse");
913            assert_eq!(failure.failure, Failure::NotAarch64);
914            assert!(failure.message.contains(detail), "{}", failure.message);
915        }
916        let x86_str = x86.display().to_string();
917        let env = move |name: &str| (name == ENV_OVERRIDE).then(|| x86_str.clone());
918        let failure =
919            resolve(&request(dir.path()), &env, &Panicking, &mut |_| {}).expect_err("must refuse");
920        assert!(
921            failure.message.contains(ENV_OVERRIDE),
922            "{}",
923            failure.message
924        );
925    }
926
927    /// **Fetched bytes that are not an aarch64 ELF are discarded, and the cache stays
928    /// empty** (BIND-20). Caching them would turn one bad download into a persistent
929    /// run-hook-timeout mystery on every later request.
930    #[test]
931    fn a_fetched_non_arm_binary_is_refused_and_not_cached() {
932        let dir = tempfile::tempdir().expect("a temp dir");
933        let failure = resolve(
934            &request(dir.path()),
935            &no_env,
936            &Scripted::with(elf_header(0x3E)),
937            &mut |_| {},
938        )
939        .expect_err("an x86 asset must refuse");
940        assert!(failure.message.contains("0x3e"), "{}", failure.message);
941        assert!(
942            !cache_path(dir.path(), "9.9.9").exists(),
943            "nothing may be cached"
944        );
945
946        let failure = resolve(
947            &request(dir.path()),
948            &no_env,
949            &Scripted::with(b"#!/bin/sh".to_vec()),
950            &mut |_| {},
951        )
952        .expect_err("a non-ELF asset must refuse");
953        assert!(
954            failure.message.contains("not an ELF"),
955            "{}",
956            failure.message
957        );
958        let left: Vec<_> = std::fs::read_dir(cache_path(dir.path(), "9.9.9").parent().unwrap())
959            .expect("the version dir exists")
960            .collect();
961        assert!(left.is_empty(), "no partial file may survive: {left:?}");
962    }
963
964    /// A failed fetch carries the tag and every way out (BIND-18).
965    #[test]
966    fn a_failed_fetch_names_the_tag_and_every_way_out() {
967        let dir = tempfile::tempdir().expect("a temp dir");
968        let failure =
969            resolve(&request(dir.path()), &no_env, &Failing, &mut |_| {}).expect_err("no network");
970        assert_eq!(failure.failure, Failure::Fetch);
971        assert!(failure.message.contains("v9.9.9"), "{}", failure.message);
972        let remedies = failure.remedies.join("\n");
973        assert!(
974            remedies.contains("gh release download v9.9.9"),
975            "{remedies}"
976        );
977        assert!(remedies.contains("--cert-identity https://github.com/laithalsaadoon/microvms-agentd/.github/workflows/release.yml@refs/tags/v9.9.9"), "{remedies}");
978        assert!(remedies.contains(ENV_OVERRIDE), "{remedies}");
979        assert!(
980            remedies.contains("cargo build --release -p agentd"),
981            "{remedies}"
982        );
983        // The binding form carries them in its one message.
984        let error = Error::from(failure);
985        assert_eq!(error.code(), "ERR_PRECONDITION");
986        assert!(error.to_string().contains("gh release download"), "{error}");
987    }
988
989    /// **A cache entry that no longer matches its record is fetched again** (BIND-19), and
990    /// so is one with no record at all.
991    #[test]
992    fn a_changed_or_unrecorded_cache_entry_is_fetched_again() {
993        let dir = tempfile::tempdir().expect("a temp dir");
994        let fetch = Scripted::elf();
995        resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("caches");
996        let cached = cache_path(dir.path(), "9.9.9");
997        let mut changed = elf_header(REQUIRED_ELF_MACHINE);
998        changed.extend_from_slice(b"changed");
999        std::fs::write(&cached, &changed).expect("overwrites");
1000        let mut lines = Vec::new();
1001        let again = resolve(&request(dir.path()), &no_env, &fetch, &mut |line| {
1002            lines.push(line.to_string())
1003        })
1004        .expect("refetches");
1005        assert_eq!(again.source.as_str(), "fetched");
1006        assert_eq!(fetch.calls.get(), 2);
1007        assert_eq!(again.bytes, fetch.bytes);
1008        assert!(
1009            lines.iter().any(|line| line.contains("does not match")),
1010            "{lines:?}"
1011        );
1012
1013        std::fs::remove_file(record_path(dir.path(), "9.9.9")).expect("removes the record");
1014        let unrecorded =
1015            resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("refetches");
1016        assert_eq!(unrecorded.source.as_str(), "fetched");
1017        assert_eq!(fetch.calls.get(), 3);
1018    }
1019
1020    /// A record for another version, or a garbled one, is no record (BIND-17, BIND-19).
1021    #[test]
1022    fn a_record_for_another_version_does_not_vouch_for_this_one() {
1023        let dir = tempfile::tempdir().expect("a temp dir");
1024        let fetch = Scripted::elf();
1025        resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("caches");
1026        let digest = sha256_hex(&fetch.bytes);
1027        let record = record_path(dir.path(), "9.9.9");
1028        std::fs::write(
1029            &record,
1030            record_json("1.0.0", &digest, Verification::Attestation),
1031        )
1032        .expect("writes");
1033        resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("refetches");
1034        assert_eq!(fetch.calls.get(), 2);
1035        std::fs::write(&record, "{not json").expect("writes");
1036        resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("refetches");
1037        assert_eq!(fetch.calls.get(), 3);
1038    }
1039
1040    /// Versions that are not plain tags are refused before anything touches the disk.
1041    #[test]
1042    fn a_version_that_is_not_a_tag_is_refused() {
1043        for bad in [
1044            "", "v", "../x", "1.0/../x", ".hidden", "-rf", "1..2", "a b", "1\n",
1045        ] {
1046            let failure = normalize_version(bad).expect_err(bad);
1047            assert_eq!(failure.kind(), ErrorKind::InvalidArg, "{bad:?}");
1048        }
1049        assert_eq!(normalize_version("v0.9.0").unwrap(), "0.9.0");
1050        assert_eq!(
1051            normalize_version("1.2.3-rc.1+build").unwrap(),
1052            "1.2.3-rc.1+build"
1053        );
1054    }
1055}