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/// Whether a rename onto `target` installed `expected`: it did when the rename succeeded, and
392/// also when the rename failed but `target` already holds exactly `expected`. Windows refuses a
393/// rename onto a file another rename is replacing at that moment (`ERROR_ACCESS_DENIED`), so of
394/// two callers installing the same verified bytes, one can be refused after the other has put
395/// those bytes in place (#717). Any other content, or no file, keeps the rename's own error.
396fn installed_or_identical(
397    renamed: std::io::Result<()>,
398    target: &Path,
399    expected: &[u8],
400) -> std::io::Result<()> {
401    let Err(error) = renamed else {
402        return Ok(());
403    };
404    match std::fs::read(target) {
405        Ok(present) if present == expected => Ok(()),
406        _ => Err(error),
407    }
408}
409
410/// A filesystem failure under the state directory.
411fn io_error(path: &Path, verb: &str, error: &std::io::Error) -> ProvisionError {
412    ProvisionError::new(
413        Failure::Io,
414        format!("could not {verb} {}: {error}", path.display()),
415    )
416    .remedy("the failure is on this machine's filesystem; the platform was not involved")
417}
418
419/// A failed fetch, with every way out named: the manual download, the override variable,
420/// and the self-build. This is the error a fresh machine with no `gh` and no network sees,
421/// so it carries the whole story rather than pointing at a doc.
422fn fetch_error(tag: &str, reason: &str) -> ProvisionError {
423    ProvisionError::new(
424        Failure::Fetch,
425        format!("could not provision the agentd daemon binary for {tag}: {reason}"),
426    )
427    .remedy(format!(
428        "manual download: `gh release download {tag} --repo {RELEASE_REPO} --pattern {ASSET}` \
429         (then `gh attestation verify {ASSET} --repo {RELEASE_REPO} \
430         --cert-identity https://github.com/{RELEASE_REPO}/.github/workflows/release.yml@refs/tags/{tag}`), and pass its path as \
431         the binary or ${ENV_OVERRIDE}"
432    ))
433    .remedy(
434        "a self-built daemon works too: cargo build --release -p agentd --target \
435         aarch64-unknown-linux-musl",
436    )
437}
438
439/// Answers `request` from the caller's binary, the cache, or the release, in that order.
440///
441/// `env` supplies `$MICROVM_AGENTD` and, when [`Request::state_dir`] is `None`, the
442/// variables [`crate::names::state_root`] reads. `fetch` is the release seam;
443/// `progress` receives one line per step.
444pub fn resolve(
445    request: &Request<'_>,
446    env: &dyn Fn(&str) -> Option<String>,
447    fetch: &dyn Fetch,
448    progress: &mut dyn FnMut(&str),
449) -> Result<Provisioned, ProvisionError> {
450    let version = normalize_version(request.version.unwrap_or(crate::VERSION))?;
451
452    // The caller's binary first: a caller who named one manages it, and a cache hit that
453    // silently outranked it would run a daemon they did not choose.
454    let supplied = match request.binary {
455        Some(path) => Some((path.to_path_buf(), Supplier::Argument)),
456        None => env(ENV_OVERRIDE).map(|path| (PathBuf::from(path), Supplier::Env)),
457    };
458    if let Some((path, supplier)) = supplied {
459        return caller_supplied(path, supplier, version, progress);
460    }
461
462    let state_dir = match request.state_dir {
463        Some(dir) => dir.to_path_buf(),
464        None => crate::names::state_root(env)
465            .map_err(|error| ProvisionError::new(Failure::Io, error.to_string()))?,
466    };
467    let cached = cache_path(&state_dir, &version);
468    if let Some(hit) = cache_hit(&state_dir, &cached, &version, progress)? {
469        return Ok(hit);
470    }
471    fetched(&state_dir, &cached, version, fetch, progress)
472}
473
474fn caller_supplied(
475    path: PathBuf,
476    supplier: Supplier,
477    version: String,
478    progress: &mut dyn FnMut(&str),
479) -> Result<Provisioned, ProvisionError> {
480    let named = match supplier {
481        Supplier::Argument => format!("the agentd binary {}", path.display()),
482        Supplier::Env => format!("${ENV_OVERRIDE} names {}, which", path.display()),
483    };
484    let refuse = |failure: Failure, detail: String| {
485        let error = ProvisionError::new(failure, format!("{named} {detail}"));
486        match supplier {
487            Supplier::Argument => error,
488            Supplier::Env => error
489                .remedy(format!(
490                    "unset {ENV_OVERRIDE} to provision the release asset instead"
491                ))
492                .remedy("or point it at a real aarch64 agentd binary"),
493        }
494    };
495    if !path.exists() {
496        return Err(refuse(
497            Failure::CallerBinary(supplier),
498            "does not exist. A caller-supplied binary short-circuits provisioning, so a \
499             stale path blocks the fetch that would otherwise have worked."
500                .to_string(),
501        ));
502    }
503    let bytes = std::fs::read(&path).map_err(|error| {
504        refuse(
505            Failure::CallerBinary(supplier),
506            format!("could not be read: {error}"),
507        )
508    })?;
509    if let Some(why) = not_aarch64(&bytes) {
510        return Err(refuse(
511            Failure::NotAarch64,
512            format!(
513                "is {why}. MicroVMs are ARM64-only, and a wrong-architecture daemon fails as \
514                 a run-hook timeout 45 minutes into a build."
515            ),
516        )
517        .remedy("build one: cargo build --release -p agentd --target aarch64-unknown-linux-musl"));
518    }
519    match supplier {
520        Supplier::Argument => progress(&format!("using the caller's agentd: {}", path.display())),
521        Supplier::Env => progress(&format!("using ${ENV_OVERRIDE}: {}", path.display())),
522    }
523    Ok(Provisioned {
524        sha256: sha256_hex(&bytes),
525        bytes,
526        path,
527        source: Source::CallerSupplied(supplier),
528        version,
529    })
530}
531
532/// The cache entry for `version`, when its bytes still match its record; otherwise the
533/// entry is ignored and `None` sends the caller to a fetch (BIND-19).
534fn cache_hit(
535    state_dir: &Path,
536    cached: &Path,
537    version: &str,
538    progress: &mut dyn FnMut(&str),
539) -> Result<Option<Provisioned>, ProvisionError> {
540    let bytes = match std::fs::read(cached) {
541        Ok(bytes) => bytes,
542        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
543        Err(error) => return Err(io_error(cached, "read", &error)),
544    };
545    let sha256 = sha256_hex(&bytes);
546    match recorded(state_dir, version, &sha256) {
547        Some(verification) if not_aarch64(&bytes).is_none() => {
548            progress(&format!(
549                "using cached agentd v{version}: {}",
550                cached.display()
551            ));
552            Ok(Some(Provisioned {
553                bytes,
554                path: cached.to_path_buf(),
555                source: Source::Cache(verification),
556                version: version.to_string(),
557                sha256,
558            }))
559        }
560        _ => {
561            progress(&format!(
562                "cached agentd v{version} at {} does not match the digest recorded when it \
563                 was verified; ignoring it and fetching the release again",
564                cached.display()
565            ));
566            // Another caller may have installed the binary and not its record yet.
567            // Never unlink either path here: only this call's verified replacement
568            // may change the cache, so a concurrent successful install stays usable.
569            Ok(None)
570        }
571    }
572}
573
574fn fetched(
575    state_dir: &Path,
576    cached: &Path,
577    version: String,
578    fetch: &dyn Fetch,
579    progress: &mut dyn FnMut(&str),
580) -> Result<Provisioned, ProvisionError> {
581    let tag = format!("v{version}");
582    progress(&format!(
583        "no agentd given; fetching the release asset for {tag}"
584    ));
585    let dir = cached.parent().expect("the cache path has a parent");
586    std::fs::create_dir_all(dir).map_err(|error| io_error(dir, "create", &error))?;
587    // Every call owns its temporary files. A PID-only name lets a second thread
588    // truncate a verified download before the first thread reads or installs it.
589    // Keep the path guard but close its handle: a legacy fetch may replace the
590    // destination, which Windows refuses while this file is still open.
591    let download = tempfile::NamedTempFile::new_in(dir)
592        .map_err(|error| io_error(dir, "create temporary download in", &error))?
593        .into_temp_path();
594    let Fetched {
595        bytes,
596        verification,
597    } = fetch
598        .fetch_bytes(&tag, &download, progress)
599        .map_err(|reason| fetch_error(&tag, &reason))?;
600    if let Some(why) = not_aarch64(&bytes) {
601        return Err(ProvisionError::new(
602            Failure::NotAarch64,
603            format!(
604                "the fetched {tag} asset is {why}, so it was discarded rather than cached: a \
605                 wrong-architecture daemon fails as a run-hook timeout 45 minutes into a build."
606            ),
607        )
608        .remedy("this is a release-asset defect worth reporting, not a local mistake"));
609    }
610
611    // A legacy Fetch may atomically replace its destination. Use a fresh install
612    // handle so its bytes and permissions always belong to the file we persist.
613    drop(download);
614    let mut partial = tempfile::NamedTempFile::new_in(dir)
615        .map_err(|error| io_error(dir, "create temporary binary in", &error))?;
616    partial
617        .as_file_mut()
618        .set_len(0)
619        .and_then(|()| partial.write_all(&bytes))
620        .map_err(|error| io_error(partial.path(), "write", &error))?;
621    #[cfg(unix)]
622    {
623        use std::os::unix::fs::PermissionsExt as _;
624        partial
625            .as_file()
626            .set_permissions(std::fs::Permissions::from_mode(0o755))
627            .map_err(|error| io_error(partial.path(), "chmod", &error))?;
628    }
629    let installed = partial
630        .into_temp_path()
631        .persist(cached)
632        .map_err(|error| error.error);
633    installed_or_identical(installed, cached, &bytes)
634        .map_err(|error| io_error(cached, "install", &error))?;
635    // The record after the binary: a crash between the two leaves a binary with no record,
636    // which the next request ignores and fetches again rather than trusts.
637    let sha256 = sha256_hex(&bytes);
638    let record = record_path(state_dir, &version);
639    let record_body = record_json(&version, &sha256, verification);
640    let mut record_partial = tempfile::NamedTempFile::new_in(dir)
641        .map_err(|error| io_error(dir, "create temporary record in", &error))?;
642    record_partial
643        .write_all(record_body.as_bytes())
644        .map_err(|error| io_error(&record, "write record", &error))?;
645    let recorded = record_partial
646        .into_temp_path()
647        .persist(&record)
648        .map_err(|error| error.error);
649    installed_or_identical(recorded, &record, record_body.as_bytes())
650        .map_err(|error| io_error(&record, "record", &error))?;
651    progress(&format!(
652        "fetched and verified agentd {tag} ({}); cached at {}",
653        verification.as_str(),
654        cached.display()
655    ));
656    Ok(Provisioned {
657        bytes,
658        path: cached.to_path_buf(),
659        source: Source::Fetched(verification),
660        version,
661        sha256,
662    })
663}
664
665/// **The one call.** The daemon binary for `version` (default: [`crate::VERSION`]) under
666/// `state_dir` (default: the CLI's), from `$MICROVM_AGENTD`, the cache, or a verified fetch
667/// over HTTPS ([`HttpsFetch`], with `$GITHUB_TOKEN` when it's set).
668///
669/// Blocking: a fetch downloads a few MiB and can take seconds.
670pub fn agentd(version: Option<&str>, state_dir: Option<&Path>) -> Result<Provisioned, Error> {
671    agentd_with(&Request {
672        version,
673        state_dir,
674        binary: None,
675    })
676}
677
678/// [`agentd`] with a caller-supplied binary as well: the bindings' entry point.
679pub fn agentd_with(request: &Request<'_>) -> Result<Provisioned, Error> {
680    let env = &crate::env::process;
681    resolve(request, env, &HttpsFetch::from_env(env), &mut |_| {}).map_err(Error::from)
682}
683
684#[cfg(test)]
685mod tests {
686    use super::*;
687    use std::cell::Cell;
688
689    /// A rename refused after an identical install still installs (#717). A Linux or macOS
690    /// rename replaces its target, so the Windows refusal is injected as the `PermissionDenied`
691    /// std maps `ERROR_ACCESS_DENIED` to; other bytes, or no file, keep that refusal.
692    ///
693    /// **Falsification**: return the rename's error without reading the target, and the
694    /// identical case fails with "identical bytes are installed".
695    #[test]
696    fn a_rename_refused_after_an_identical_install_still_installs() {
697        let dir = tempfile::tempdir().expect("a temporary directory");
698        let target = dir.path().join("agentd");
699        let refused = || Err(std::io::Error::from(std::io::ErrorKind::PermissionDenied));
700        std::fs::write(&target, b"verified").expect("writes");
701        installed_or_identical(refused(), &target, b"verified")
702            .expect("identical bytes are installed");
703        let other = installed_or_identical(refused(), &target, b"different")
704            .expect_err("other bytes keep the refusal");
705        assert_eq!(other.kind(), std::io::ErrorKind::PermissionDenied);
706        std::fs::remove_file(&target).expect("removes");
707        let missing = installed_or_identical(refused(), &target, b"verified")
708            .expect_err("a missing target keeps the refusal");
709        assert_eq!(missing.kind(), std::io::ErrorKind::PermissionDenied);
710        installed_or_identical(Ok(()), &target, b"verified")
711            .expect("a rename that succeeded is installed");
712    }
713
714    /// A scripted fetch: writes `bytes` to the destination and counts invocations.
715    struct Scripted {
716        bytes: Vec<u8>,
717        verification: Verification,
718        calls: Cell<usize>,
719    }
720
721    impl Scripted {
722        fn elf() -> Self {
723            Self::with(elf_header(REQUIRED_ELF_MACHINE))
724        }
725
726        fn with(bytes: Vec<u8>) -> Self {
727            Self {
728                bytes,
729                verification: Verification::Attestation,
730                calls: Cell::new(0),
731            }
732        }
733    }
734
735    impl Fetch for Scripted {
736        fn fetch(
737            &self,
738            _: &str,
739            dest: &Path,
740            _: &mut dyn FnMut(&str),
741        ) -> Result<Verification, String> {
742            self.calls.set(self.calls.get() + 1);
743            std::fs::write(dest, &self.bytes).expect("writes");
744            Ok(self.verification)
745        }
746    }
747
748    /// A fetch that always fails, for the error-path assertions.
749    struct Failing;
750
751    impl Fetch for Failing {
752        fn fetch(
753            &self,
754            _: &str,
755            _: &Path,
756            _: &mut dyn FnMut(&str),
757        ) -> Result<Verification, String> {
758            Err("no network in tests".into())
759        }
760    }
761
762    /// A fetch that must not run.
763    struct Panicking;
764
765    impl Fetch for Panicking {
766        fn fetch(
767            &self,
768            tag: &str,
769            _: &Path,
770            _: &mut dyn FnMut(&str),
771        ) -> Result<Verification, String> {
772            panic!("this request must not fetch (asked for {tag})");
773        }
774    }
775
776    /// A 20-byte little-endian ELF header for `machine`.
777    fn elf_header(machine: u16) -> Vec<u8> {
778        let mut header = vec![0u8; 20];
779        header[..4].copy_from_slice(b"\x7fELF");
780        header[5] = 1;
781        header[18..20].copy_from_slice(&machine.to_le_bytes());
782        header
783    }
784
785    fn no_env(_: &str) -> Option<String> {
786        None
787    }
788
789    fn request(dir: &Path) -> Request<'_> {
790        Request {
791            version: Some("9.9.9"),
792            state_dir: Some(dir),
793            binary: None,
794        }
795    }
796
797    #[test]
798    fn provisioning_requires_an_absolute_default_root() {
799        const CHILD: &str = "MICROVMS_TEST_STATE_ROOT_CHILD";
800        if std::env::var_os(CHILD).is_some() {
801            struct Unused;
802            impl Fetch for Unused {
803                fn fetch(
804                    &self,
805                    _: &str,
806                    _: &Path,
807                    _: &mut dyn FnMut(&str),
808                ) -> Result<Verification, String> {
809                    panic!("an unresolved default root must not start a fetch");
810                }
811            }
812            let request = Request {
813                binary: None,
814                state_dir: None,
815                version: None,
816            };
817            let error = resolve(
818                &request,
819                &|key| matches!(key, "HOME" | "USERPROFILE").then(|| "relative-home".to_string()),
820                &Unused,
821                &mut |_| {},
822            )
823            .expect_err("relative default root");
824            assert!(error.message.contains("absolute user home"), "{error}");
825            assert!(!Path::new("relative-home").exists());
826            return;
827        }
828        // A subprocess gives even the regression's filesystem writes a scratch-only cwd,
829        // without changing the working directory of parallel tests.
830        let directory = tempfile::tempdir().expect("scratch directory");
831        let output = std::process::Command::new(std::env::current_exe().expect("test binary"))
832            .args([
833                "--exact",
834                "provision::tests::provisioning_requires_an_absolute_default_root",
835            ])
836            .env(CHILD, "1")
837            .current_dir(directory.path())
838            .output()
839            .expect("child test");
840        assert!(
841            output.status.success(),
842            "{}",
843            String::from_utf8_lossy(&output.stdout)
844        );
845    }
846
847    /// **A cache miss fetches once; the next request is a cache hit** (BIND-17). The
848    /// property the module exists for: one download per version per machine.
849    #[test]
850    fn a_cache_miss_fetches_once_and_the_next_request_reads_the_cache() {
851        let dir = tempfile::tempdir().expect("a temp dir");
852        let fetch = Scripted::elf();
853        let first = resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("resolves");
854        assert_eq!(first.source, Source::Fetched(Verification::Attestation));
855        assert_eq!(fetch.calls.get(), 1);
856        assert_eq!(first.bytes, std::fs::read(&first.path).expect("reads"));
857        #[cfg(unix)]
858        {
859            use std::os::unix::fs::PermissionsExt as _;
860            let mode = std::fs::metadata(&first.path)
861                .expect("stat")
862                .permissions()
863                .mode();
864            assert_eq!(
865                mode & 0o111,
866                0o111,
867                "the installed binary must be executable"
868            );
869        }
870
871        let second = resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("resolves");
872        assert_eq!(second.source, Source::Cache(Verification::Attestation));
873        assert_eq!(fetch.calls.get(), 1, "a cache hit must not fetch again");
874        assert_eq!(second.path, first.path);
875        assert_eq!(second.sha256, first.sha256);
876    }
877
878    /// The version defaults to the core's own, with or without a `v` (BIND-17).
879    #[test]
880    fn the_version_defaults_to_the_cores_own_and_accepts_a_leading_v() {
881        let dir = tempfile::tempdir().expect("a temp dir");
882        let fetch = Scripted::elf();
883        let default = Request {
884            state_dir: Some(dir.path()),
885            ..Request::default()
886        };
887        let served = resolve(&default, &no_env, &fetch, &mut |_| {}).expect("resolves");
888        assert_eq!(served.version, crate::VERSION);
889        assert_eq!(served.path, cache_path(dir.path(), crate::VERSION));
890        let tagged = format!("v{}", crate::VERSION);
891        let again = Request {
892            version: Some(&tagged),
893            ..default
894        };
895        let hit = resolve(&again, &no_env, &Panicking, &mut |_| {}).expect("resolves");
896        assert_eq!(hit.source.as_str(), "cache");
897    }
898
899    /// **The caller's binary outranks the cache and never fetches** (BIND-17), and the
900    /// argument outranks `$MICROVM_AGENTD`.
901    #[test]
902    fn a_caller_binary_outranks_the_environment_and_the_cache() {
903        let dir = tempfile::tempdir().expect("a temp dir");
904        resolve(&request(dir.path()), &no_env, &Scripted::elf(), &mut |_| {}).expect("caches");
905        let own = dir.path().join("my-agentd");
906        let mut bytes = elf_header(REQUIRED_ELF_MACHINE);
907        bytes.extend_from_slice(b"caller-managed");
908        std::fs::write(&own, &bytes).expect("writes");
909        let env_path = dir.path().join("env-agentd");
910        std::fs::write(&env_path, elf_header(REQUIRED_ELF_MACHINE)).expect("writes");
911        let env_str = env_path.display().to_string();
912        let env = move |name: &str| (name == ENV_OVERRIDE).then(|| env_str.clone());
913
914        let from_env = resolve(&request(dir.path()), &env, &Panicking, &mut |_| {}).expect("env");
915        assert_eq!(from_env.source, Source::CallerSupplied(Supplier::Env));
916        assert_eq!(from_env.path, env_path);
917        assert_eq!(from_env.verification(), None);
918
919        let with_binary = Request {
920            binary: Some(&own),
921            ..request(dir.path())
922        };
923        let from_arg = resolve(&with_binary, &env, &Panicking, &mut |_| {}).expect("argument");
924        assert_eq!(from_arg.source, Source::CallerSupplied(Supplier::Argument));
925        assert_eq!(from_arg.bytes, bytes);
926    }
927
928    /// A `$MICROVM_AGENTD` pointing at nothing is an error naming the variable, not a
929    /// fall-through to a fetch the caller opted out of.
930    #[test]
931    fn a_stale_environment_override_is_an_error_rather_than_a_fallthrough() {
932        let dir = tempfile::tempdir().expect("a temp dir");
933        let env = |name: &str| (name == ENV_OVERRIDE).then(|| "/definitely/not/here".to_string());
934        let failure = resolve(&request(dir.path()), &env, &Panicking, &mut |_| {})
935            .expect_err("a stale override must refuse");
936        assert_eq!(failure.failure, Failure::CallerBinary(Supplier::Env));
937        assert_eq!(failure.kind(), ErrorKind::Precondition);
938        assert!(
939            failure.message.contains(ENV_OVERRIDE),
940            "{}",
941            failure.message
942        );
943        assert!(failure.remedies.iter().any(|r| r.contains("unset")));
944    }
945
946    /// **A caller-supplied binary that is not an aarch64 ELF is refused** (BIND-20), from
947    /// either supplier, and never falls through to a fetch.
948    #[test]
949    fn a_caller_binary_that_is_not_aarch64_is_refused() {
950        let dir = tempfile::tempdir().expect("a temp dir");
951        let x86 = dir.path().join("x86");
952        std::fs::write(&x86, elf_header(0x3E)).expect("writes");
953        let script = dir.path().join("script");
954        std::fs::write(&script, b"#!/bin/sh\n").expect("writes");
955        for (path, detail) in [(&x86, "ELF machine 0x3e"), (&script, "not an ELF")] {
956            let with_binary = Request {
957                binary: Some(path),
958                ..request(dir.path())
959            };
960            let failure =
961                resolve(&with_binary, &no_env, &Panicking, &mut |_| {}).expect_err("must refuse");
962            assert_eq!(failure.failure, Failure::NotAarch64);
963            assert!(failure.message.contains(detail), "{}", failure.message);
964        }
965        let x86_str = x86.display().to_string();
966        let env = move |name: &str| (name == ENV_OVERRIDE).then(|| x86_str.clone());
967        let failure =
968            resolve(&request(dir.path()), &env, &Panicking, &mut |_| {}).expect_err("must refuse");
969        assert!(
970            failure.message.contains(ENV_OVERRIDE),
971            "{}",
972            failure.message
973        );
974    }
975
976    /// **Fetched bytes that are not an aarch64 ELF are discarded, and the cache stays
977    /// empty** (BIND-20). Caching them would turn one bad download into a persistent
978    /// run-hook-timeout mystery on every later request.
979    #[test]
980    fn a_fetched_non_arm_binary_is_refused_and_not_cached() {
981        let dir = tempfile::tempdir().expect("a temp dir");
982        let failure = resolve(
983            &request(dir.path()),
984            &no_env,
985            &Scripted::with(elf_header(0x3E)),
986            &mut |_| {},
987        )
988        .expect_err("an x86 asset must refuse");
989        assert!(failure.message.contains("0x3e"), "{}", failure.message);
990        assert!(
991            !cache_path(dir.path(), "9.9.9").exists(),
992            "nothing may be cached"
993        );
994
995        let failure = resolve(
996            &request(dir.path()),
997            &no_env,
998            &Scripted::with(b"#!/bin/sh".to_vec()),
999            &mut |_| {},
1000        )
1001        .expect_err("a non-ELF asset must refuse");
1002        assert!(
1003            failure.message.contains("not an ELF"),
1004            "{}",
1005            failure.message
1006        );
1007        let left: Vec<_> = std::fs::read_dir(cache_path(dir.path(), "9.9.9").parent().unwrap())
1008            .expect("the version dir exists")
1009            .collect();
1010        assert!(left.is_empty(), "no partial file may survive: {left:?}");
1011    }
1012
1013    /// A failed fetch carries the tag and every way out (BIND-18).
1014    #[test]
1015    fn a_failed_fetch_names_the_tag_and_every_way_out() {
1016        let dir = tempfile::tempdir().expect("a temp dir");
1017        let failure =
1018            resolve(&request(dir.path()), &no_env, &Failing, &mut |_| {}).expect_err("no network");
1019        assert_eq!(failure.failure, Failure::Fetch);
1020        assert!(failure.message.contains("v9.9.9"), "{}", failure.message);
1021        let remedies = failure.remedies.join("\n");
1022        assert!(
1023            remedies.contains("gh release download v9.9.9"),
1024            "{remedies}"
1025        );
1026        assert!(remedies.contains("--cert-identity https://github.com/laithalsaadoon/microvms-agentd/.github/workflows/release.yml@refs/tags/v9.9.9"), "{remedies}");
1027        assert!(remedies.contains(ENV_OVERRIDE), "{remedies}");
1028        assert!(
1029            remedies.contains("cargo build --release -p agentd"),
1030            "{remedies}"
1031        );
1032        // The binding form carries them in its one message.
1033        let error = Error::from(failure);
1034        assert_eq!(error.code(), "ERR_PRECONDITION");
1035        assert!(error.to_string().contains("gh release download"), "{error}");
1036    }
1037
1038    /// **A cache entry that no longer matches its record is fetched again** (BIND-19), and
1039    /// so is one with no record at all.
1040    #[test]
1041    fn a_changed_or_unrecorded_cache_entry_is_fetched_again() {
1042        let dir = tempfile::tempdir().expect("a temp dir");
1043        let fetch = Scripted::elf();
1044        resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("caches");
1045        let cached = cache_path(dir.path(), "9.9.9");
1046        let mut changed = elf_header(REQUIRED_ELF_MACHINE);
1047        changed.extend_from_slice(b"changed");
1048        std::fs::write(&cached, &changed).expect("overwrites");
1049        let mut lines = Vec::new();
1050        let again = resolve(&request(dir.path()), &no_env, &fetch, &mut |line| {
1051            lines.push(line.to_string())
1052        })
1053        .expect("refetches");
1054        assert_eq!(again.source.as_str(), "fetched");
1055        assert_eq!(fetch.calls.get(), 2);
1056        assert_eq!(again.bytes, fetch.bytes);
1057        assert!(
1058            lines.iter().any(|line| line.contains("does not match")),
1059            "{lines:?}"
1060        );
1061
1062        std::fs::remove_file(record_path(dir.path(), "9.9.9")).expect("removes the record");
1063        let unrecorded =
1064            resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("refetches");
1065        assert_eq!(unrecorded.source.as_str(), "fetched");
1066        assert_eq!(fetch.calls.get(), 3);
1067    }
1068
1069    /// A record for another version, or a garbled one, is no record (BIND-17, BIND-19).
1070    #[test]
1071    fn a_record_for_another_version_does_not_vouch_for_this_one() {
1072        let dir = tempfile::tempdir().expect("a temp dir");
1073        let fetch = Scripted::elf();
1074        resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("caches");
1075        let digest = sha256_hex(&fetch.bytes);
1076        let record = record_path(dir.path(), "9.9.9");
1077        std::fs::write(
1078            &record,
1079            record_json("1.0.0", &digest, Verification::Attestation),
1080        )
1081        .expect("writes");
1082        resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("refetches");
1083        assert_eq!(fetch.calls.get(), 2);
1084        std::fs::write(&record, "{not json").expect("writes");
1085        resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("refetches");
1086        assert_eq!(fetch.calls.get(), 3);
1087    }
1088
1089    /// Versions that are not plain tags are refused before anything touches the disk.
1090    #[test]
1091    fn a_version_that_is_not_a_tag_is_refused() {
1092        for bad in [
1093            "", "v", "../x", "1.0/../x", ".hidden", "-rf", "1..2", "a b", "1\n",
1094        ] {
1095            let failure = normalize_version(bad).expect_err(bad);
1096            assert_eq!(failure.kind(), ErrorKind::InvalidArg, "{bad:?}");
1097        }
1098        assert_eq!(normalize_version("v0.9.0").unwrap(), "0.9.0");
1099        assert_eq!(
1100            normalize_version("1.2.3-rc.1+build").unwrap(),
1101            "1.2.3-rc.1+build"
1102        );
1103    }
1104}