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    /// ([`microvms_app::names::default_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}`), and pass its path as \
392         the binary or ${ENV_OVERRIDE}"
393    ))
394    .remedy(
395        "a self-built daemon works too: cargo build --release -p agentd --target \
396         aarch64-unknown-linux-musl",
397    )
398}
399
400/// Answers `request` from the caller's binary, the cache, or the release, in that order.
401///
402/// `env` supplies `$MICROVM_AGENTD` and, when [`Request::state_dir`] is `None`, the
403/// variables [`microvms_app::names::default_state_root`] reads. `fetch` is the release seam;
404/// `progress` receives one line per step.
405pub fn resolve(
406    request: &Request<'_>,
407    env: &dyn Fn(&str) -> Option<String>,
408    fetch: &dyn Fetch,
409    progress: &mut dyn FnMut(&str),
410) -> Result<Provisioned, ProvisionError> {
411    let version = normalize_version(request.version.unwrap_or(crate::VERSION))?;
412
413    // The caller's binary first: a caller who named one manages it, and a cache hit that
414    // silently outranked it would run a daemon they did not choose.
415    let supplied = match request.binary {
416        Some(path) => Some((path.to_path_buf(), Supplier::Argument)),
417        None => env(ENV_OVERRIDE).map(|path| (PathBuf::from(path), Supplier::Env)),
418    };
419    if let Some((path, supplier)) = supplied {
420        return caller_supplied(path, supplier, version, progress);
421    }
422
423    let state_dir = match request.state_dir {
424        Some(dir) => dir.to_path_buf(),
425        None => microvms_app::names::default_state_root(env),
426    };
427    let cached = cache_path(&state_dir, &version);
428    if let Some(hit) = cache_hit(&state_dir, &cached, &version, progress)? {
429        return Ok(hit);
430    }
431    fetched(&state_dir, &cached, version, fetch, progress)
432}
433
434fn caller_supplied(
435    path: PathBuf,
436    supplier: Supplier,
437    version: String,
438    progress: &mut dyn FnMut(&str),
439) -> Result<Provisioned, ProvisionError> {
440    let named = match supplier {
441        Supplier::Argument => format!("the agentd binary {}", path.display()),
442        Supplier::Env => format!("${ENV_OVERRIDE} names {}, which", path.display()),
443    };
444    let refuse = |failure: Failure, detail: String| {
445        let error = ProvisionError::new(failure, format!("{named} {detail}"));
446        match supplier {
447            Supplier::Argument => error,
448            Supplier::Env => error
449                .remedy(format!(
450                    "unset {ENV_OVERRIDE} to provision the release asset instead"
451                ))
452                .remedy("or point it at a real aarch64 agentd binary"),
453        }
454    };
455    if !path.exists() {
456        return Err(refuse(
457            Failure::CallerBinary(supplier),
458            "does not exist. A caller-supplied binary short-circuits provisioning, so a \
459             stale path blocks the fetch that would otherwise have worked."
460                .to_string(),
461        ));
462    }
463    let bytes = std::fs::read(&path).map_err(|error| {
464        refuse(
465            Failure::CallerBinary(supplier),
466            format!("could not be read: {error}"),
467        )
468    })?;
469    if let Some(why) = not_aarch64(&bytes) {
470        return Err(refuse(
471            Failure::NotAarch64,
472            format!(
473                "is {why}. MicroVMs are ARM64-only, and a wrong-architecture daemon fails as \
474                 a run-hook timeout 45 minutes into a build."
475            ),
476        )
477        .remedy("build one: cargo build --release -p agentd --target aarch64-unknown-linux-musl"));
478    }
479    match supplier {
480        Supplier::Argument => progress(&format!("using the caller's agentd: {}", path.display())),
481        Supplier::Env => progress(&format!("using ${ENV_OVERRIDE}: {}", path.display())),
482    }
483    Ok(Provisioned {
484        sha256: sha256_hex(&bytes),
485        bytes,
486        path,
487        source: Source::CallerSupplied(supplier),
488        version,
489    })
490}
491
492/// The cache entry for `version`, when its bytes still match its record; otherwise the
493/// entry is discarded and `None` sends the caller to a fetch (BIND-19).
494fn cache_hit(
495    state_dir: &Path,
496    cached: &Path,
497    version: &str,
498    progress: &mut dyn FnMut(&str),
499) -> Result<Option<Provisioned>, ProvisionError> {
500    let bytes = match std::fs::read(cached) {
501        Ok(bytes) => bytes,
502        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
503        Err(error) => return Err(io_error(cached, "read", &error)),
504    };
505    let sha256 = sha256_hex(&bytes);
506    match recorded(state_dir, version, &sha256) {
507        Some(verification) if not_aarch64(&bytes).is_none() => {
508            progress(&format!(
509                "using cached agentd v{version}: {}",
510                cached.display()
511            ));
512            Ok(Some(Provisioned {
513                bytes,
514                path: cached.to_path_buf(),
515                source: Source::Cache(verification),
516                version: version.to_string(),
517                sha256,
518            }))
519        }
520        _ => {
521            progress(&format!(
522                "cached agentd v{version} at {} does not match the digest recorded when it \
523                 was verified; discarding it and fetching the release again",
524                cached.display()
525            ));
526            for path in [cached.to_path_buf(), record_path(state_dir, version)] {
527                match std::fs::remove_file(&path) {
528                    Ok(()) => {}
529                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
530                    Err(error) => return Err(io_error(&path, "discard", &error)),
531                }
532            }
533            Ok(None)
534        }
535    }
536}
537
538fn fetched(
539    state_dir: &Path,
540    cached: &Path,
541    version: String,
542    fetch: &dyn Fetch,
543    progress: &mut dyn FnMut(&str),
544) -> Result<Provisioned, ProvisionError> {
545    let tag = format!("v{version}");
546    progress(&format!(
547        "no agentd given; fetching the release asset for {tag}"
548    ));
549    let dir = cached.parent().expect("the cache path has a parent");
550    std::fs::create_dir_all(dir).map_err(|error| io_error(dir, "create", &error))?;
551    // A partial name in the same directory, so the final `rename` is atomic on the same
552    // filesystem: an interrupted download leaves a `.partial` nothing trusts, never a
553    // truncated `agentd` the next request reads.
554    let partial = dir.join(format!(".{ASSET}.partial-{}", std::process::id()));
555    let verification = match fetch.fetch(&tag, &partial, progress) {
556        Ok(verification) => verification,
557        Err(reason) => {
558            let _ = std::fs::remove_file(&partial);
559            return Err(fetch_error(&tag, &reason));
560        }
561    };
562    let bytes = std::fs::read(&partial).map_err(|error| {
563        let _ = std::fs::remove_file(&partial);
564        io_error(&partial, "read", &error)
565    })?;
566    if let Some(why) = not_aarch64(&bytes) {
567        let _ = std::fs::remove_file(&partial);
568        return Err(ProvisionError::new(
569            Failure::NotAarch64,
570            format!(
571                "the fetched {tag} asset is {why}, so it was discarded rather than cached: a \
572                 wrong-architecture daemon fails as a run-hook timeout 45 minutes into a build."
573            ),
574        )
575        .remedy("this is a release-asset defect worth reporting, not a local mistake"));
576    }
577
578    #[cfg(unix)]
579    {
580        use std::os::unix::fs::PermissionsExt as _;
581        std::fs::set_permissions(&partial, std::fs::Permissions::from_mode(0o755))
582            .map_err(|error| io_error(&partial, "chmod", &error))?;
583    }
584    std::fs::rename(&partial, cached).map_err(|error| io_error(cached, "install", &error))?;
585    // The record after the binary: a crash between the two leaves a binary with no record,
586    // which the next request discards and fetches again rather than trusts.
587    let sha256 = sha256_hex(&bytes);
588    let record = record_path(state_dir, &version);
589    let record_partial = dir.join(format!(".{RECORD}.partial-{}", std::process::id()));
590    std::fs::write(
591        &record_partial,
592        record_json(&version, &sha256, verification),
593    )
594    .and_then(|()| std::fs::rename(&record_partial, &record))
595    .map_err(|error| {
596        let _ = std::fs::remove_file(&record_partial);
597        io_error(&record, "record", &error)
598    })?;
599    progress(&format!(
600        "fetched and verified agentd {tag} ({}); cached at {}",
601        verification.as_str(),
602        cached.display()
603    ));
604    Ok(Provisioned {
605        bytes,
606        path: cached.to_path_buf(),
607        source: Source::Fetched(verification),
608        version,
609        sha256,
610    })
611}
612
613/// **The one call.** The daemon binary for `version` (default: [`crate::VERSION`]) under
614/// `state_dir` (default: the CLI's), from `$MICROVM_AGENTD`, the cache, or a verified fetch
615/// over HTTPS ([`HttpsFetch`], with `$GITHUB_TOKEN` when it's set).
616///
617/// Blocking: a fetch downloads a few MiB and can take seconds.
618pub fn agentd(version: Option<&str>, state_dir: Option<&Path>) -> Result<Provisioned, Error> {
619    agentd_with(&Request {
620        version,
621        state_dir,
622        binary: None,
623    })
624}
625
626/// [`agentd`] with a caller-supplied binary as well: the bindings' entry point.
627pub fn agentd_with(request: &Request<'_>) -> Result<Provisioned, Error> {
628    let env = &crate::env::process;
629    resolve(request, env, &HttpsFetch::from_env(env), &mut |_| {}).map_err(Error::from)
630}
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635    use std::cell::Cell;
636
637    /// A scripted fetch: writes `bytes` to the destination and counts invocations.
638    struct Scripted {
639        bytes: Vec<u8>,
640        verification: Verification,
641        calls: Cell<usize>,
642    }
643
644    impl Scripted {
645        fn elf() -> Self {
646            Self::with(elf_header(REQUIRED_ELF_MACHINE))
647        }
648
649        fn with(bytes: Vec<u8>) -> Self {
650            Self {
651                bytes,
652                verification: Verification::Attestation,
653                calls: Cell::new(0),
654            }
655        }
656    }
657
658    impl Fetch for Scripted {
659        fn fetch(
660            &self,
661            _: &str,
662            dest: &Path,
663            _: &mut dyn FnMut(&str),
664        ) -> Result<Verification, String> {
665            self.calls.set(self.calls.get() + 1);
666            std::fs::write(dest, &self.bytes).expect("writes");
667            Ok(self.verification)
668        }
669    }
670
671    /// A fetch that always fails, for the error-path assertions.
672    struct Failing;
673
674    impl Fetch for Failing {
675        fn fetch(
676            &self,
677            _: &str,
678            _: &Path,
679            _: &mut dyn FnMut(&str),
680        ) -> Result<Verification, String> {
681            Err("no network in tests".into())
682        }
683    }
684
685    /// A fetch that must not run.
686    struct Panicking;
687
688    impl Fetch for Panicking {
689        fn fetch(
690            &self,
691            tag: &str,
692            _: &Path,
693            _: &mut dyn FnMut(&str),
694        ) -> Result<Verification, String> {
695            panic!("this request must not fetch (asked for {tag})");
696        }
697    }
698
699    /// A 20-byte little-endian ELF header for `machine`.
700    fn elf_header(machine: u16) -> Vec<u8> {
701        let mut header = vec![0u8; 20];
702        header[..4].copy_from_slice(b"\x7fELF");
703        header[5] = 1;
704        header[18..20].copy_from_slice(&machine.to_le_bytes());
705        header
706    }
707
708    fn no_env(_: &str) -> Option<String> {
709        None
710    }
711
712    fn request(dir: &Path) -> Request<'_> {
713        Request {
714            version: Some("9.9.9"),
715            state_dir: Some(dir),
716            binary: None,
717        }
718    }
719
720    /// **A cache miss fetches once; the next request is a cache hit** (BIND-17). The
721    /// property the module exists for: one download per version per machine.
722    #[test]
723    fn a_cache_miss_fetches_once_and_the_next_request_reads_the_cache() {
724        let dir = tempfile::tempdir().expect("a temp dir");
725        let fetch = Scripted::elf();
726        let first = resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("resolves");
727        assert_eq!(first.source, Source::Fetched(Verification::Attestation));
728        assert_eq!(fetch.calls.get(), 1);
729        assert_eq!(first.bytes, std::fs::read(&first.path).expect("reads"));
730        #[cfg(unix)]
731        {
732            use std::os::unix::fs::PermissionsExt as _;
733            let mode = std::fs::metadata(&first.path)
734                .expect("stat")
735                .permissions()
736                .mode();
737            assert_eq!(
738                mode & 0o111,
739                0o111,
740                "the installed binary must be executable"
741            );
742        }
743
744        let second = resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("resolves");
745        assert_eq!(second.source, Source::Cache(Verification::Attestation));
746        assert_eq!(fetch.calls.get(), 1, "a cache hit must not fetch again");
747        assert_eq!(second.path, first.path);
748        assert_eq!(second.sha256, first.sha256);
749    }
750
751    /// The version defaults to the core's own, with or without a `v` (BIND-17).
752    #[test]
753    fn the_version_defaults_to_the_cores_own_and_accepts_a_leading_v() {
754        let dir = tempfile::tempdir().expect("a temp dir");
755        let fetch = Scripted::elf();
756        let default = Request {
757            state_dir: Some(dir.path()),
758            ..Request::default()
759        };
760        let served = resolve(&default, &no_env, &fetch, &mut |_| {}).expect("resolves");
761        assert_eq!(served.version, crate::VERSION);
762        assert_eq!(served.path, cache_path(dir.path(), crate::VERSION));
763        let tagged = format!("v{}", crate::VERSION);
764        let again = Request {
765            version: Some(&tagged),
766            ..default
767        };
768        let hit = resolve(&again, &no_env, &Panicking, &mut |_| {}).expect("resolves");
769        assert_eq!(hit.source.as_str(), "cache");
770    }
771
772    /// **The caller's binary outranks the cache and never fetches** (BIND-17), and the
773    /// argument outranks `$MICROVM_AGENTD`.
774    #[test]
775    fn a_caller_binary_outranks_the_environment_and_the_cache() {
776        let dir = tempfile::tempdir().expect("a temp dir");
777        resolve(&request(dir.path()), &no_env, &Scripted::elf(), &mut |_| {}).expect("caches");
778        let own = dir.path().join("my-agentd");
779        let mut bytes = elf_header(REQUIRED_ELF_MACHINE);
780        bytes.extend_from_slice(b"caller-managed");
781        std::fs::write(&own, &bytes).expect("writes");
782        let env_path = dir.path().join("env-agentd");
783        std::fs::write(&env_path, elf_header(REQUIRED_ELF_MACHINE)).expect("writes");
784        let env_str = env_path.display().to_string();
785        let env = move |name: &str| (name == ENV_OVERRIDE).then(|| env_str.clone());
786
787        let from_env = resolve(&request(dir.path()), &env, &Panicking, &mut |_| {}).expect("env");
788        assert_eq!(from_env.source, Source::CallerSupplied(Supplier::Env));
789        assert_eq!(from_env.path, env_path);
790        assert_eq!(from_env.verification(), None);
791
792        let with_binary = Request {
793            binary: Some(&own),
794            ..request(dir.path())
795        };
796        let from_arg = resolve(&with_binary, &env, &Panicking, &mut |_| {}).expect("argument");
797        assert_eq!(from_arg.source, Source::CallerSupplied(Supplier::Argument));
798        assert_eq!(from_arg.bytes, bytes);
799    }
800
801    /// A `$MICROVM_AGENTD` pointing at nothing is an error naming the variable, not a
802    /// fall-through to a fetch the caller opted out of.
803    #[test]
804    fn a_stale_environment_override_is_an_error_rather_than_a_fallthrough() {
805        let dir = tempfile::tempdir().expect("a temp dir");
806        let env = |name: &str| (name == ENV_OVERRIDE).then(|| "/definitely/not/here".to_string());
807        let failure = resolve(&request(dir.path()), &env, &Panicking, &mut |_| {})
808            .expect_err("a stale override must refuse");
809        assert_eq!(failure.failure, Failure::CallerBinary(Supplier::Env));
810        assert_eq!(failure.kind(), ErrorKind::Precondition);
811        assert!(
812            failure.message.contains(ENV_OVERRIDE),
813            "{}",
814            failure.message
815        );
816        assert!(failure.remedies.iter().any(|r| r.contains("unset")));
817    }
818
819    /// **A caller-supplied binary that is not an aarch64 ELF is refused** (BIND-20), from
820    /// either supplier, and never falls through to a fetch.
821    #[test]
822    fn a_caller_binary_that_is_not_aarch64_is_refused() {
823        let dir = tempfile::tempdir().expect("a temp dir");
824        let x86 = dir.path().join("x86");
825        std::fs::write(&x86, elf_header(0x3E)).expect("writes");
826        let script = dir.path().join("script");
827        std::fs::write(&script, b"#!/bin/sh\n").expect("writes");
828        for (path, detail) in [(&x86, "ELF machine 0x3e"), (&script, "not an ELF")] {
829            let with_binary = Request {
830                binary: Some(path),
831                ..request(dir.path())
832            };
833            let failure =
834                resolve(&with_binary, &no_env, &Panicking, &mut |_| {}).expect_err("must refuse");
835            assert_eq!(failure.failure, Failure::NotAarch64);
836            assert!(failure.message.contains(detail), "{}", failure.message);
837        }
838        let x86_str = x86.display().to_string();
839        let env = move |name: &str| (name == ENV_OVERRIDE).then(|| x86_str.clone());
840        let failure =
841            resolve(&request(dir.path()), &env, &Panicking, &mut |_| {}).expect_err("must refuse");
842        assert!(
843            failure.message.contains(ENV_OVERRIDE),
844            "{}",
845            failure.message
846        );
847    }
848
849    /// **Fetched bytes that are not an aarch64 ELF are discarded, and the cache stays
850    /// empty** (BIND-20). Caching them would turn one bad download into a persistent
851    /// run-hook-timeout mystery on every later request.
852    #[test]
853    fn a_fetched_non_arm_binary_is_refused_and_not_cached() {
854        let dir = tempfile::tempdir().expect("a temp dir");
855        let failure = resolve(
856            &request(dir.path()),
857            &no_env,
858            &Scripted::with(elf_header(0x3E)),
859            &mut |_| {},
860        )
861        .expect_err("an x86 asset must refuse");
862        assert!(failure.message.contains("0x3e"), "{}", failure.message);
863        assert!(
864            !cache_path(dir.path(), "9.9.9").exists(),
865            "nothing may be cached"
866        );
867
868        let failure = resolve(
869            &request(dir.path()),
870            &no_env,
871            &Scripted::with(b"#!/bin/sh".to_vec()),
872            &mut |_| {},
873        )
874        .expect_err("a non-ELF asset must refuse");
875        assert!(
876            failure.message.contains("not an ELF"),
877            "{}",
878            failure.message
879        );
880        let left: Vec<_> = std::fs::read_dir(cache_path(dir.path(), "9.9.9").parent().unwrap())
881            .expect("the version dir exists")
882            .collect();
883        assert!(left.is_empty(), "no partial file may survive: {left:?}");
884    }
885
886    /// A failed fetch carries the tag and every way out (BIND-18).
887    #[test]
888    fn a_failed_fetch_names_the_tag_and_every_way_out() {
889        let dir = tempfile::tempdir().expect("a temp dir");
890        let failure =
891            resolve(&request(dir.path()), &no_env, &Failing, &mut |_| {}).expect_err("no network");
892        assert_eq!(failure.failure, Failure::Fetch);
893        assert!(failure.message.contains("v9.9.9"), "{}", failure.message);
894        let remedies = failure.remedies.join("\n");
895        assert!(remedies.contains("gh release download"), "{remedies}");
896        assert!(remedies.contains(ENV_OVERRIDE), "{remedies}");
897        assert!(
898            remedies.contains("cargo build --release -p agentd"),
899            "{remedies}"
900        );
901        // The binding form carries them in its one message.
902        let error = Error::from(failure);
903        assert_eq!(error.code(), "ERR_PRECONDITION");
904        assert!(error.to_string().contains("gh release download"), "{error}");
905    }
906
907    /// **A cache entry that no longer matches its record is fetched again** (BIND-19), and
908    /// so is one with no record at all.
909    #[test]
910    fn a_changed_or_unrecorded_cache_entry_is_fetched_again() {
911        let dir = tempfile::tempdir().expect("a temp dir");
912        let fetch = Scripted::elf();
913        resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("caches");
914        let cached = cache_path(dir.path(), "9.9.9");
915        let mut changed = elf_header(REQUIRED_ELF_MACHINE);
916        changed.extend_from_slice(b"changed");
917        std::fs::write(&cached, &changed).expect("overwrites");
918        let mut lines = Vec::new();
919        let again = resolve(&request(dir.path()), &no_env, &fetch, &mut |line| {
920            lines.push(line.to_string())
921        })
922        .expect("refetches");
923        assert_eq!(again.source.as_str(), "fetched");
924        assert_eq!(fetch.calls.get(), 2);
925        assert_eq!(again.bytes, fetch.bytes);
926        assert!(
927            lines.iter().any(|line| line.contains("does not match")),
928            "{lines:?}"
929        );
930
931        std::fs::remove_file(record_path(dir.path(), "9.9.9")).expect("removes the record");
932        let unrecorded =
933            resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("refetches");
934        assert_eq!(unrecorded.source.as_str(), "fetched");
935        assert_eq!(fetch.calls.get(), 3);
936    }
937
938    /// A record for another version, or a garbled one, is no record (BIND-17, BIND-19).
939    #[test]
940    fn a_record_for_another_version_does_not_vouch_for_this_one() {
941        let dir = tempfile::tempdir().expect("a temp dir");
942        let fetch = Scripted::elf();
943        resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("caches");
944        let digest = sha256_hex(&fetch.bytes);
945        let record = record_path(dir.path(), "9.9.9");
946        std::fs::write(
947            &record,
948            record_json("1.0.0", &digest, Verification::Attestation),
949        )
950        .expect("writes");
951        resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("refetches");
952        assert_eq!(fetch.calls.get(), 2);
953        std::fs::write(&record, "{not json").expect("writes");
954        resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("refetches");
955        assert_eq!(fetch.calls.get(), 3);
956    }
957
958    /// Versions that are not plain tags are refused before anything touches the disk.
959    #[test]
960    fn a_version_that_is_not_a_tag_is_refused() {
961        for bad in [
962            "", "v", "../x", "1.0/../x", ".hidden", "-rf", "1..2", "a b", "1\n",
963        ] {
964            let failure = normalize_version(bad).expect_err(bad);
965            assert_eq!(failure.kind(), ErrorKind::InvalidArg, "{bad:?}");
966        }
967        assert_eq!(normalize_version("v0.9.0").unwrap(), "0.9.0");
968        assert_eq!(
969            normalize_version("1.2.3-rc.1+build").unwrap(),
970            "1.2.3-rc.1+build"
971        );
972    }
973}