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