use std::fmt;
use std::path::{Path, PathBuf};
use crate::error::{Error, ErrorKind};
pub const RELEASE_REPO: &str = "laithalsaadoon/microvms-agentd";
pub const ASSET: &str = "agentd";
pub const ENV_OVERRIDE: &str = "MICROVM_AGENTD";
pub const REQUIRED_ELF_MACHINE: u16 = 0xB7;
const RECORD: &str = "agentd.verified.json";
const MAX_VERSION_LEN: usize = 64;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Verification {
Attestation,
Checksum,
}
impl Verification {
pub fn as_str(self) -> &'static str {
match self {
Verification::Attestation => "attestation",
Verification::Checksum => "checksum",
}
}
fn parse(text: &str) -> Option<Self> {
match text {
"attestation" => Some(Verification::Attestation),
"checksum" => Some(Verification::Checksum),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Supplier {
Argument,
Env,
}
impl Supplier {
pub fn as_str(self) -> &'static str {
match self {
Supplier::Argument => "argument",
Supplier::Env => "env",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Source {
CallerSupplied(Supplier),
Cache(Verification),
Fetched(Verification),
}
impl Source {
pub fn as_str(self) -> &'static str {
match self {
Source::CallerSupplied(_) => "caller-supplied",
Source::Cache(_) => "cache",
Source::Fetched(_) => "fetched",
}
}
pub fn verification(self) -> Option<Verification> {
match self {
Source::CallerSupplied(_) => None,
Source::Cache(verification) | Source::Fetched(verification) => Some(verification),
}
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct Provisioned {
pub bytes: Vec<u8>,
pub path: PathBuf,
pub source: Source,
pub version: String,
pub sha256: String,
}
impl Provisioned {
pub fn verification(&self) -> Option<Verification> {
self.source.verification()
}
}
impl fmt::Debug for Provisioned {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Provisioned")
.field("bytes", &format_args!("<{} bytes>", self.bytes.len()))
.field("path", &self.path)
.field("source", &self.source)
.field("version", &self.version)
.field("sha256", &self.sha256)
.finish()
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Request<'a> {
pub version: Option<&'a str>,
pub state_dir: Option<&'a Path>,
pub binary: Option<&'a Path>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Failure {
InvalidVersion,
CallerBinary(Supplier),
NotAarch64,
Fetch,
Io,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ProvisionError {
pub failure: Failure,
pub message: String,
pub remedies: Vec<String>,
}
impl ProvisionError {
fn new(failure: Failure, message: impl Into<String>) -> Self {
Self {
failure,
message: message.into(),
remedies: Vec::new(),
}
}
fn remedy(mut self, remedy: impl Into<String>) -> Self {
self.remedies.push(remedy.into());
self
}
pub fn kind(&self) -> ErrorKind {
match self.failure {
Failure::InvalidVersion => ErrorKind::InvalidArg,
_ => ErrorKind::Precondition,
}
}
}
impl fmt::Display for ProvisionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for ProvisionError {}
impl From<ProvisionError> for Error {
fn from(error: ProvisionError) -> Self {
let mut message = error.message.clone();
for remedy in &error.remedies {
message.push_str("\n - ");
message.push_str(remedy);
}
Error::new(error.kind(), message)
}
}
pub trait Fetch {
fn fetch(
&self,
tag: &str,
dest: &Path,
progress: &mut dyn FnMut(&str),
) -> Result<Verification, String>;
}
pub trait Runner {
fn run(&self, argv: &[String]) -> Result<(), String>;
}
impl<R: Runner + ?Sized> Runner for &R {
fn run(&self, argv: &[String]) -> Result<(), String> {
(**self).run(argv)
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Subprocess;
impl Runner for Subprocess {
fn run(&self, argv: &[String]) -> Result<(), String> {
let output = std::process::Command::new(&argv[0])
.args(&argv[1..])
.stdin(std::process::Stdio::null())
.output()
.map_err(|error| format!("`{}` did not run: {error}", argv[0]))?;
if output.status.success() {
return Ok(());
}
let detail = String::from_utf8_lossy(&output.stderr);
let detail = detail.trim();
Err(format!(
"`{}` exited {}: {}",
argv.join(" "),
output.status.code().unwrap_or(-1),
if detail.is_empty() {
"(no stderr)"
} else {
detail
},
))
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct ReleaseFetch<R>(pub R);
impl<R: Runner> Fetch for ReleaseFetch<R> {
fn fetch(
&self,
tag: &str,
dest: &Path,
progress: &mut dyn FnMut(&str),
) -> Result<Verification, String> {
let runner = &self.0;
match runner.run(&gh_download_args(tag, dest)) {
Ok(()) => {
progress(&format!(
"downloaded {ASSET} {tag} via gh; verifying provenance"
));
return match runner.run(&gh_verify_args(dest)) {
Ok(()) => Ok(Verification::Attestation),
Err(reason) => {
let _ = std::fs::remove_file(dest);
Err(format!(
"`gh attestation verify` refused the downloaded asset: {reason}. \
The bytes were discarded; do not retry with verification off."
))
}
};
}
Err(gh_reason) => {
progress(&format!("gh could not download ({gh_reason}); trying curl"));
}
}
runner
.run(&curl_args(&asset_url(tag, ASSET), dest))
.map_err(|curl_reason| {
format!(
"neither tool could download {ASSET} {tag} from {RELEASE_REPO}: gh and \
curl both failed, most recently: {curl_reason}"
)
})?;
let sums_dest = dest.with_extension("sums");
let sums = runner
.run(&curl_args(&asset_url(tag, "SHA256SUMS"), &sums_dest))
.and_then(|()| std::fs::read_to_string(&sums_dest).map_err(|error| error.to_string()));
let _ = std::fs::remove_file(&sums_dest);
let sums = sums.map_err(|reason| {
let _ = std::fs::remove_file(dest);
format!(
"downloaded {ASSET} {tag} via curl, but could not fetch the release's \
SHA256SUMS to verify it ({reason}). curl alone proves nothing about the \
bytes, so this fails closed. Releases before v0.5.0 ship no SHA256SUMS; \
for those, authenticate `gh` and retry, or download and verify manually."
)
})?;
let bytes = std::fs::read(dest).map_err(|error| error.to_string())?;
if let Err(reason) = verify_sha256(&sums, ASSET, &bytes) {
let _ = std::fs::remove_file(dest);
return Err(reason);
}
progress(&format!(
"downloaded {ASSET} {tag} via curl; SHA256SUMS entry matched"
));
Ok(Verification::Checksum)
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct SubprocessFetch;
impl Fetch for SubprocessFetch {
fn fetch(
&self,
tag: &str,
dest: &Path,
progress: &mut dyn FnMut(&str),
) -> Result<Verification, String> {
ReleaseFetch(Subprocess).fetch(tag, dest, progress)
}
}
fn asset_url(tag: &str, asset: &str) -> String {
format!("https://github.com/{RELEASE_REPO}/releases/download/{tag}/{asset}")
}
fn gh_download_args(tag: &str, dest: &Path) -> Vec<String> {
vec![
"gh".into(),
"release".into(),
"download".into(),
tag.into(),
"--repo".into(),
RELEASE_REPO.into(),
"--pattern".into(),
ASSET.into(),
"--output".into(),
dest.display().to_string(),
"--clobber".into(),
]
}
fn gh_verify_args(dest: &Path) -> Vec<String> {
vec![
"gh".into(),
"attestation".into(),
"verify".into(),
dest.display().to_string(),
"--repo".into(),
RELEASE_REPO.into(),
]
}
fn curl_args(url: &str, dest: &Path) -> Vec<String> {
vec![
"curl".into(),
"-sSfL".into(),
"--proto".into(),
"=https".into(),
"--max-time".into(),
"300".into(),
"--output".into(),
dest.display().to_string(),
url.into(),
]
}
pub fn verify_sha256(sums: &str, asset: &str, bytes: &[u8]) -> Result<(), String> {
let expected = sums
.lines()
.filter_map(|line| {
let mut parts = line.split_whitespace();
let digest = parts.next()?;
let name = parts.next()?;
(name.trim_start_matches('*') == asset).then(|| digest.to_ascii_lowercase())
})
.next()
.ok_or_else(|| {
format!(
"the release's SHA256SUMS has no entry for {asset}, so nothing to verify against"
)
})?;
let actual = sha256_hex(bytes);
if actual == expected {
return Ok(());
}
Err(format!(
"SHA256 mismatch for {asset}: the release says {expected}, the download hashed to \
{actual}. The bytes were not installed; retry, and if it repeats, treat the \
mismatch as the finding rather than the obstacle."
))
}
pub fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest as _, Sha256};
const_hex::encode(Sha256::digest(bytes))
}
pub fn elf_machine(bytes: &[u8]) -> Option<u16> {
let header = bytes.get(..20)?;
if &header[..4] != b"\x7fELF" {
return None;
}
let field = [header[18], header[19]];
Some(if header[5] == 1 {
u16::from_le_bytes(field)
} else {
u16::from_be_bytes(field)
})
}
pub fn not_aarch64(bytes: &[u8]) -> Option<String> {
match elf_machine(bytes) {
Some(REQUIRED_ELF_MACHINE) => None,
Some(machine) => Some(format!(
"ELF machine 0x{machine:x}, not aarch64 (0x{REQUIRED_ELF_MACHINE:x})"
)),
None => Some("not an ELF binary at all".to_string()),
}
}
pub fn normalize_version(version: &str) -> Result<String, ProvisionError> {
let bare = version.strip_prefix('v').unwrap_or(version);
let plain = !bare.is_empty()
&& bare.len() <= MAX_VERSION_LEN
&& bare.as_bytes()[0].is_ascii_alphanumeric()
&& !bare.contains("..")
&& bare
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'+' | b'-'));
if plain {
return Ok(bare.to_string());
}
Err(ProvisionError::new(
Failure::InvalidVersion,
format!(
"{version:?} is not a release version: expected a tag such as {} or v{}, made of \
letters, digits, `.`, `+`, and `-`",
crate::VERSION,
crate::VERSION
),
)
.remedy("omit the version to provision the core's own, which is the one it is proven against"))
}
pub fn cache_path(state_dir: &Path, version: &str) -> PathBuf {
state_dir
.join("agentd")
.join(format!("v{version}"))
.join(ASSET)
}
fn record_path(state_dir: &Path, version: &str) -> PathBuf {
state_dir
.join("agentd")
.join(format!("v{version}"))
.join(RECORD)
}
pub(crate) fn record_json(version: &str, sha256: &str, verification: Verification) -> String {
serde_json::json!({
"version": version,
"sha256": sha256,
"verification": verification.as_str(),
})
.to_string()
}
fn recorded(state_dir: &Path, version: &str, sha256: &str) -> Option<Verification> {
let text = std::fs::read_to_string(record_path(state_dir, version)).ok()?;
parse_record(&text, version, sha256)
}
pub(crate) fn parse_record(text: &str, version: &str, sha256: &str) -> Option<Verification> {
let record: serde_json::Value = serde_json::from_str(text).ok()?;
let matches =
record["version"].as_str() == Some(version) && record["sha256"].as_str() == Some(sha256);
matches
.then(|| Verification::parse(record["verification"].as_str()?))
.flatten()
}
fn io_error(path: &Path, verb: &str, error: &std::io::Error) -> ProvisionError {
ProvisionError::new(
Failure::Io,
format!("could not {verb} {}: {error}", path.display()),
)
.remedy("the failure is on this machine's filesystem; the platform was not involved")
}
fn fetch_error(tag: &str, reason: &str) -> ProvisionError {
ProvisionError::new(
Failure::Fetch,
format!("could not provision the agentd daemon binary for {tag}: {reason}"),
)
.remedy(format!(
"manual download: `gh release download {tag} --repo {RELEASE_REPO} --pattern {ASSET}` \
(then `gh attestation verify {ASSET} --repo {RELEASE_REPO}`), and pass its path as \
the binary or ${ENV_OVERRIDE}"
))
.remedy(
"a self-built daemon works too: cargo build --release -p agentd --target \
aarch64-unknown-linux-musl",
)
}
pub fn resolve(
request: &Request<'_>,
env: &dyn Fn(&str) -> Option<String>,
fetch: &dyn Fetch,
progress: &mut dyn FnMut(&str),
) -> Result<Provisioned, ProvisionError> {
let version = normalize_version(request.version.unwrap_or(crate::VERSION))?;
let supplied = match request.binary {
Some(path) => Some((path.to_path_buf(), Supplier::Argument)),
None => env(ENV_OVERRIDE).map(|path| (PathBuf::from(path), Supplier::Env)),
};
if let Some((path, supplier)) = supplied {
return caller_supplied(path, supplier, version, progress);
}
let state_dir = match request.state_dir {
Some(dir) => dir.to_path_buf(),
None => crate::names::default_state_root(env),
};
let cached = cache_path(&state_dir, &version);
if let Some(hit) = cache_hit(&state_dir, &cached, &version, progress)? {
return Ok(hit);
}
fetched(&state_dir, &cached, version, fetch, progress)
}
fn caller_supplied(
path: PathBuf,
supplier: Supplier,
version: String,
progress: &mut dyn FnMut(&str),
) -> Result<Provisioned, ProvisionError> {
let named = match supplier {
Supplier::Argument => format!("the agentd binary {}", path.display()),
Supplier::Env => format!("${ENV_OVERRIDE} names {}, which", path.display()),
};
let refuse = |failure: Failure, detail: String| {
let error = ProvisionError::new(failure, format!("{named} {detail}"));
match supplier {
Supplier::Argument => error,
Supplier::Env => error
.remedy(format!(
"unset {ENV_OVERRIDE} to provision the release asset instead"
))
.remedy("or point it at a real aarch64 agentd binary"),
}
};
if !path.exists() {
return Err(refuse(
Failure::CallerBinary(supplier),
"does not exist. A caller-supplied binary short-circuits provisioning, so a \
stale path blocks the fetch that would otherwise have worked."
.to_string(),
));
}
let bytes = std::fs::read(&path).map_err(|error| {
refuse(
Failure::CallerBinary(supplier),
format!("could not be read: {error}"),
)
})?;
if let Some(why) = not_aarch64(&bytes) {
return Err(refuse(
Failure::NotAarch64,
format!(
"is {why}. MicroVMs are ARM64-only, and a wrong-architecture daemon fails as \
a run-hook timeout 45 minutes into a build."
),
)
.remedy("build one: cargo build --release -p agentd --target aarch64-unknown-linux-musl"));
}
match supplier {
Supplier::Argument => progress(&format!("using the caller's agentd: {}", path.display())),
Supplier::Env => progress(&format!("using ${ENV_OVERRIDE}: {}", path.display())),
}
Ok(Provisioned {
sha256: sha256_hex(&bytes),
bytes,
path,
source: Source::CallerSupplied(supplier),
version,
})
}
fn cache_hit(
state_dir: &Path,
cached: &Path,
version: &str,
progress: &mut dyn FnMut(&str),
) -> Result<Option<Provisioned>, ProvisionError> {
let bytes = match std::fs::read(cached) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(io_error(cached, "read", &error)),
};
let sha256 = sha256_hex(&bytes);
match recorded(state_dir, version, &sha256) {
Some(verification) if not_aarch64(&bytes).is_none() => {
progress(&format!(
"using cached agentd v{version}: {}",
cached.display()
));
Ok(Some(Provisioned {
bytes,
path: cached.to_path_buf(),
source: Source::Cache(verification),
version: version.to_string(),
sha256,
}))
}
_ => {
progress(&format!(
"cached agentd v{version} at {} does not match the digest recorded when it \
was verified; discarding it and fetching the release again",
cached.display()
));
for path in [cached.to_path_buf(), record_path(state_dir, version)] {
match std::fs::remove_file(&path) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(io_error(&path, "discard", &error)),
}
}
Ok(None)
}
}
}
fn fetched(
state_dir: &Path,
cached: &Path,
version: String,
fetch: &dyn Fetch,
progress: &mut dyn FnMut(&str),
) -> Result<Provisioned, ProvisionError> {
let tag = format!("v{version}");
progress(&format!(
"no agentd given; fetching the release asset for {tag}"
));
let dir = cached.parent().expect("the cache path has a parent");
std::fs::create_dir_all(dir).map_err(|error| io_error(dir, "create", &error))?;
let partial = dir.join(format!(".{ASSET}.partial-{}", std::process::id()));
let verification = match fetch.fetch(&tag, &partial, progress) {
Ok(verification) => verification,
Err(reason) => {
let _ = std::fs::remove_file(&partial);
return Err(fetch_error(&tag, &reason));
}
};
let bytes = std::fs::read(&partial).map_err(|error| {
let _ = std::fs::remove_file(&partial);
io_error(&partial, "read", &error)
})?;
if let Some(why) = not_aarch64(&bytes) {
let _ = std::fs::remove_file(&partial);
return Err(ProvisionError::new(
Failure::NotAarch64,
format!(
"the fetched {tag} asset is {why}, so it was discarded rather than cached: a \
wrong-architecture daemon fails as a run-hook timeout 45 minutes into a build."
),
)
.remedy("this is a release-asset defect worth reporting, not a local mistake"));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(&partial, std::fs::Permissions::from_mode(0o755))
.map_err(|error| io_error(&partial, "chmod", &error))?;
}
std::fs::rename(&partial, cached).map_err(|error| io_error(cached, "install", &error))?;
let sha256 = sha256_hex(&bytes);
let record = record_path(state_dir, &version);
let record_partial = dir.join(format!(".{RECORD}.partial-{}", std::process::id()));
std::fs::write(
&record_partial,
record_json(&version, &sha256, verification),
)
.and_then(|()| std::fs::rename(&record_partial, &record))
.map_err(|error| {
let _ = std::fs::remove_file(&record_partial);
io_error(&record, "record", &error)
})?;
progress(&format!(
"fetched and verified agentd {tag} ({}); cached at {}",
verification.as_str(),
cached.display()
));
Ok(Provisioned {
bytes,
path: cached.to_path_buf(),
source: Source::Fetched(verification),
version,
sha256,
})
}
fn process_env(name: &str) -> Option<String> {
std::env::var(name).ok()
}
pub fn agentd(version: Option<&str>, state_dir: Option<&Path>) -> Result<Provisioned, Error> {
agentd_with(&Request {
version,
state_dir,
binary: None,
})
}
pub fn agentd_with(request: &Request<'_>) -> Result<Provisioned, Error> {
resolve(request, &process_env, &SubprocessFetch, &mut |_| {}).map_err(Error::from)
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::{Cell, RefCell};
struct Scripted {
bytes: Vec<u8>,
verification: Verification,
calls: Cell<usize>,
}
impl Scripted {
fn elf() -> Self {
Self::with(elf_header(REQUIRED_ELF_MACHINE))
}
fn with(bytes: Vec<u8>) -> Self {
Self {
bytes,
verification: Verification::Attestation,
calls: Cell::new(0),
}
}
}
impl Fetch for Scripted {
fn fetch(
&self,
_: &str,
dest: &Path,
_: &mut dyn FnMut(&str),
) -> Result<Verification, String> {
self.calls.set(self.calls.get() + 1);
std::fs::write(dest, &self.bytes).expect("writes");
Ok(self.verification)
}
}
struct Failing;
impl Fetch for Failing {
fn fetch(
&self,
_: &str,
_: &Path,
_: &mut dyn FnMut(&str),
) -> Result<Verification, String> {
Err("no network in tests".into())
}
}
struct Panicking;
impl Fetch for Panicking {
fn fetch(
&self,
tag: &str,
_: &Path,
_: &mut dyn FnMut(&str),
) -> Result<Verification, String> {
panic!("this request must not fetch (asked for {tag})");
}
}
fn elf_header(machine: u16) -> Vec<u8> {
let mut header = vec![0u8; 20];
header[..4].copy_from_slice(b"\x7fELF");
header[5] = 1;
header[18..20].copy_from_slice(&machine.to_le_bytes());
header
}
fn no_env(_: &str) -> Option<String> {
None
}
fn request(dir: &Path) -> Request<'_> {
Request {
version: Some("9.9.9"),
state_dir: Some(dir),
binary: None,
}
}
#[test]
fn a_cache_miss_fetches_once_and_the_next_request_reads_the_cache() {
let dir = tempfile::tempdir().expect("a temp dir");
let fetch = Scripted::elf();
let first = resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("resolves");
assert_eq!(first.source, Source::Fetched(Verification::Attestation));
assert_eq!(fetch.calls.get(), 1);
assert_eq!(first.bytes, std::fs::read(&first.path).expect("reads"));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let mode = std::fs::metadata(&first.path)
.expect("stat")
.permissions()
.mode();
assert_eq!(
mode & 0o111,
0o111,
"the installed binary must be executable"
);
}
let second = resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("resolves");
assert_eq!(second.source, Source::Cache(Verification::Attestation));
assert_eq!(fetch.calls.get(), 1, "a cache hit must not fetch again");
assert_eq!(second.path, first.path);
assert_eq!(second.sha256, first.sha256);
}
#[test]
fn the_version_defaults_to_the_cores_own_and_accepts_a_leading_v() {
let dir = tempfile::tempdir().expect("a temp dir");
let fetch = Scripted::elf();
let default = Request {
state_dir: Some(dir.path()),
..Request::default()
};
let served = resolve(&default, &no_env, &fetch, &mut |_| {}).expect("resolves");
assert_eq!(served.version, crate::VERSION);
assert_eq!(served.path, cache_path(dir.path(), crate::VERSION));
let tagged = format!("v{}", crate::VERSION);
let again = Request {
version: Some(&tagged),
..default
};
let hit = resolve(&again, &no_env, &Panicking, &mut |_| {}).expect("resolves");
assert_eq!(hit.source.as_str(), "cache");
}
#[test]
fn a_caller_binary_outranks_the_environment_and_the_cache() {
let dir = tempfile::tempdir().expect("a temp dir");
resolve(&request(dir.path()), &no_env, &Scripted::elf(), &mut |_| {}).expect("caches");
let own = dir.path().join("my-agentd");
let mut bytes = elf_header(REQUIRED_ELF_MACHINE);
bytes.extend_from_slice(b"caller-managed");
std::fs::write(&own, &bytes).expect("writes");
let env_path = dir.path().join("env-agentd");
std::fs::write(&env_path, elf_header(REQUIRED_ELF_MACHINE)).expect("writes");
let env_str = env_path.display().to_string();
let env = move |name: &str| (name == ENV_OVERRIDE).then(|| env_str.clone());
let from_env = resolve(&request(dir.path()), &env, &Panicking, &mut |_| {}).expect("env");
assert_eq!(from_env.source, Source::CallerSupplied(Supplier::Env));
assert_eq!(from_env.path, env_path);
assert_eq!(from_env.verification(), None);
let with_binary = Request {
binary: Some(&own),
..request(dir.path())
};
let from_arg = resolve(&with_binary, &env, &Panicking, &mut |_| {}).expect("argument");
assert_eq!(from_arg.source, Source::CallerSupplied(Supplier::Argument));
assert_eq!(from_arg.bytes, bytes);
}
#[test]
fn a_stale_environment_override_is_an_error_rather_than_a_fallthrough() {
let dir = tempfile::tempdir().expect("a temp dir");
let env = |name: &str| (name == ENV_OVERRIDE).then(|| "/definitely/not/here".to_string());
let failure = resolve(&request(dir.path()), &env, &Panicking, &mut |_| {})
.expect_err("a stale override must refuse");
assert_eq!(failure.failure, Failure::CallerBinary(Supplier::Env));
assert_eq!(failure.kind(), ErrorKind::Precondition);
assert!(
failure.message.contains(ENV_OVERRIDE),
"{}",
failure.message
);
assert!(failure.remedies.iter().any(|r| r.contains("unset")));
}
#[test]
fn a_caller_binary_that_is_not_aarch64_is_refused() {
let dir = tempfile::tempdir().expect("a temp dir");
let x86 = dir.path().join("x86");
std::fs::write(&x86, elf_header(0x3E)).expect("writes");
let script = dir.path().join("script");
std::fs::write(&script, b"#!/bin/sh\n").expect("writes");
for (path, detail) in [(&x86, "ELF machine 0x3e"), (&script, "not an ELF")] {
let with_binary = Request {
binary: Some(path),
..request(dir.path())
};
let failure =
resolve(&with_binary, &no_env, &Panicking, &mut |_| {}).expect_err("must refuse");
assert_eq!(failure.failure, Failure::NotAarch64);
assert!(failure.message.contains(detail), "{}", failure.message);
}
let x86_str = x86.display().to_string();
let env = move |name: &str| (name == ENV_OVERRIDE).then(|| x86_str.clone());
let failure =
resolve(&request(dir.path()), &env, &Panicking, &mut |_| {}).expect_err("must refuse");
assert!(
failure.message.contains(ENV_OVERRIDE),
"{}",
failure.message
);
}
#[test]
fn a_fetched_non_arm_binary_is_refused_and_not_cached() {
let dir = tempfile::tempdir().expect("a temp dir");
let failure = resolve(
&request(dir.path()),
&no_env,
&Scripted::with(elf_header(0x3E)),
&mut |_| {},
)
.expect_err("an x86 asset must refuse");
assert!(failure.message.contains("0x3e"), "{}", failure.message);
assert!(
!cache_path(dir.path(), "9.9.9").exists(),
"nothing may be cached"
);
let failure = resolve(
&request(dir.path()),
&no_env,
&Scripted::with(b"#!/bin/sh".to_vec()),
&mut |_| {},
)
.expect_err("a non-ELF asset must refuse");
assert!(
failure.message.contains("not an ELF"),
"{}",
failure.message
);
let left: Vec<_> = std::fs::read_dir(cache_path(dir.path(), "9.9.9").parent().unwrap())
.expect("the version dir exists")
.collect();
assert!(left.is_empty(), "no partial file may survive: {left:?}");
}
#[test]
fn a_failed_fetch_names_the_tag_and_every_way_out() {
let dir = tempfile::tempdir().expect("a temp dir");
let failure =
resolve(&request(dir.path()), &no_env, &Failing, &mut |_| {}).expect_err("no network");
assert_eq!(failure.failure, Failure::Fetch);
assert!(failure.message.contains("v9.9.9"), "{}", failure.message);
let remedies = failure.remedies.join("\n");
assert!(remedies.contains("gh release download"), "{remedies}");
assert!(remedies.contains(ENV_OVERRIDE), "{remedies}");
assert!(
remedies.contains("cargo build --release -p agentd"),
"{remedies}"
);
let error = Error::from(failure);
assert_eq!(error.code(), "ERR_PRECONDITION");
assert!(error.to_string().contains("gh release download"), "{error}");
}
#[test]
fn a_changed_or_unrecorded_cache_entry_is_fetched_again() {
let dir = tempfile::tempdir().expect("a temp dir");
let fetch = Scripted::elf();
resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("caches");
let cached = cache_path(dir.path(), "9.9.9");
let mut changed = elf_header(REQUIRED_ELF_MACHINE);
changed.extend_from_slice(b"changed");
std::fs::write(&cached, &changed).expect("overwrites");
let mut lines = Vec::new();
let again = resolve(&request(dir.path()), &no_env, &fetch, &mut |line| {
lines.push(line.to_string())
})
.expect("refetches");
assert_eq!(again.source.as_str(), "fetched");
assert_eq!(fetch.calls.get(), 2);
assert_eq!(again.bytes, fetch.bytes);
assert!(
lines.iter().any(|line| line.contains("does not match")),
"{lines:?}"
);
std::fs::remove_file(record_path(dir.path(), "9.9.9")).expect("removes the record");
let unrecorded =
resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("refetches");
assert_eq!(unrecorded.source.as_str(), "fetched");
assert_eq!(fetch.calls.get(), 3);
}
#[test]
fn a_record_for_another_version_does_not_vouch_for_this_one() {
let dir = tempfile::tempdir().expect("a temp dir");
let fetch = Scripted::elf();
resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("caches");
let digest = sha256_hex(&fetch.bytes);
let record = record_path(dir.path(), "9.9.9");
std::fs::write(
&record,
record_json("1.0.0", &digest, Verification::Attestation),
)
.expect("writes");
resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("refetches");
assert_eq!(fetch.calls.get(), 2);
std::fs::write(&record, "{not json").expect("writes");
resolve(&request(dir.path()), &no_env, &fetch, &mut |_| {}).expect("refetches");
assert_eq!(fetch.calls.get(), 3);
}
#[test]
fn a_version_that_is_not_a_tag_is_refused() {
for bad in [
"", "v", "../x", "1.0/../x", ".hidden", "-rf", "1..2", "a b", "1\n",
] {
let failure = normalize_version(bad).expect_err(bad);
assert_eq!(failure.kind(), ErrorKind::InvalidArg, "{bad:?}");
}
assert_eq!(normalize_version("v0.9.0").unwrap(), "0.9.0");
assert_eq!(
normalize_version("1.2.3-rc.1+build").unwrap(),
"1.2.3-rc.1+build"
);
}
#[test]
fn the_checksum_verification_matches_mismatches_and_refuses_a_missing_entry() {
let digest = sha256_hex(b"agentd-bytes");
let sums = format!("{digest} agentd\nother microvm-x86_64.tar.gz\n");
assert!(verify_sha256(&sums, "agentd", b"agentd-bytes").is_ok());
let starred = format!("{digest} *agentd\n");
assert!(verify_sha256(&starred, "agentd", b"agentd-bytes").is_ok());
let mismatch = verify_sha256(&sums, "agentd", b"tampered").expect_err("must refuse");
assert!(mismatch.contains(&digest), "{mismatch}");
assert!(mismatch.contains("not installed"), "{mismatch}");
let missing =
verify_sha256("abc something-else\n", "agentd", b"x").expect_err("must refuse");
assert!(missing.contains("no entry"), "{missing}");
}
#[test]
fn the_elf_header_is_read_in_its_own_byte_order() {
assert_eq!(elf_machine(&elf_header(0xB7)), Some(0xB7));
let mut big = elf_header(0);
big[5] = 2;
big[18..20].copy_from_slice(&0xB7u16.to_be_bytes());
assert_eq!(elf_machine(&big), Some(0xB7));
assert_eq!(elf_machine(b"#!/bin/sh"), None);
assert_eq!(elf_machine(&elf_header(0xB7)[..19]), None);
assert_eq!(not_aarch64(&elf_header(0xB7)), None);
}
#[test]
fn the_subprocess_argv_matches_the_documented_manual_commands() {
let dest = Path::new("/tmp/agentd");
assert_eq!(
gh_download_args("v0.5.0", dest).join(" "),
"gh release download v0.5.0 --repo laithalsaadoon/microvms-agentd \
--pattern agentd --output /tmp/agentd --clobber"
);
assert_eq!(
gh_verify_args(dest).join(" "),
"gh attestation verify /tmp/agentd --repo laithalsaadoon/microvms-agentd"
);
let curl = curl_args(&asset_url("v0.5.0", ASSET), dest).join(" ");
assert!(
curl.starts_with("curl -sSfL --proto =https --max-time 300"),
"{curl}"
);
assert!(
curl.ends_with(
"https://github.com/laithalsaadoon/microvms-agentd/releases/download/v0.5.0/agentd"
),
"{curl}"
);
}
struct Table {
gh: bool,
attested: bool,
curl: bool,
sums: Option<bool>,
asset: Vec<u8>,
ran: RefCell<Vec<String>>,
}
impl Runner for Table {
fn run(&self, argv: &[String]) -> Result<(), String> {
self.ran.borrow_mut().push(argv[..2].join(" "));
let output = || {
let at = argv.iter().position(|a| a == "--output").expect("--output");
PathBuf::from(&argv[at + 1])
};
match (argv[0].as_str(), argv[1].as_str()) {
("gh", "release") if self.gh => {
std::fs::write(output(), &self.asset).map_err(|e| e.to_string())
}
("gh", "attestation") if self.attested => Ok(()),
("curl", _) if self.curl => {
let url = argv.last().expect("a url");
if url.ends_with("/agentd") {
return std::fs::write(output(), &self.asset).map_err(|e| e.to_string());
}
let digest = match self.sums {
None => return Err("404".into()),
Some(true) => sha256_hex(&self.asset),
Some(false) => sha256_hex(b"other"),
};
std::fs::write(output(), format!("{digest} agentd\n"))
.map_err(|e| e.to_string())
}
_ => Err(format!("{} refused", argv[0])),
}
}
}
#[test]
fn the_verification_table_matches_the_model() {
type Row = (bool, bool, bool, Option<bool>, Option<Verification>);
let rows: [Row; 10] = [
(
true,
true,
true,
Some(true),
Some(Verification::Attestation),
),
(true, true, false, None, Some(Verification::Attestation)),
(true, false, true, Some(true), None),
(true, false, false, None, None),
(false, false, true, Some(true), Some(Verification::Checksum)),
(false, false, true, Some(false), None),
(false, false, true, None, None),
(false, false, false, Some(true), None),
(false, true, true, Some(true), Some(Verification::Checksum)),
(false, true, true, None, None),
];
let dir = tempfile::tempdir().expect("a temp dir");
for (gh, attested, curl, sums, expected) in rows {
let table = Table {
gh,
attested,
curl,
sums,
asset: elf_header(REQUIRED_ELF_MACHINE),
ran: RefCell::new(Vec::new()),
};
let dest = dir.path().join("partial");
let verdict = ReleaseFetch(&table)
.fetch("v9.9.9", &dest, &mut |_| {})
.ok();
let row = (gh, attested, curl, sums);
assert_eq!(verdict, expected, "{row:?}");
if verdict.is_none() {
assert!(!dest.exists(), "a refused fetch left bytes behind: {row:?}");
}
if gh && !attested {
let ran = table.ran.borrow();
assert!(
ran.iter().all(|tool| !tool.starts_with("curl")),
"a refused attestation must not fall through to curl: {ran:?}"
);
}
let _ = std::fs::remove_file(&dest);
}
}
}