use std::fmt;
use std::io::Write as _;
use std::path::{Path, PathBuf};
pub use microvms_domain::provision::{REQUIRED_ELF_MACHINE, elf_machine, not_aarch64};
use microvms_app::error::{Error, ErrorKind};
pub use microvms_app::provision::{
ASSET, AttestationVerifier, Bundles, Fetched, RELEASE_REPO, ReleaseSource, Signer,
Verification, fetch_release, sha256_hex, verify_sha256,
};
mod release;
pub use release::{
BUNDLE_ASSET, GitHubRelease, HttpsFetch, PolicyFetch, RELEASE_DIR_VARIABLE, SigstoreVerifier,
TOKEN_VARIABLE,
};
pub const ENV_OVERRIDE: &str = "MICROVM_AGENTD";
const RECORD: &str = "agentd.verified.json";
const MAX_VERSION_LEN: usize = 64;
#[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>;
fn fetch_bytes(
&self,
tag: &str,
dest: &Path,
progress: &mut dyn FnMut(&str),
) -> Result<Fetched, String> {
let verification = self.fetch(tag, dest, progress)?;
let bytes = std::fs::read(dest)
.map_err(|error| format!("could not read {}: {error}", dest.display()))?;
Ok(Fetched {
bytes,
verification,
})
}
}
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);
let written = record["verification"].as_str()?;
[Verification::Attestation, Verification::Checksum]
.into_iter()
.find(|verification| matches && verification.as_str() == written)
}
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} \
--cert-identity https://github.com/{RELEASE_REPO}/.github/workflows/release.yml@refs/tags/{tag}`), 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::state_root(env)
.map_err(|error| ProvisionError::new(Failure::Io, error.to_string()))?,
};
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; ignoring it and fetching the release again",
cached.display()
));
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 download = tempfile::NamedTempFile::new_in(dir)
.map_err(|error| io_error(dir, "create temporary download in", &error))?
.into_temp_path();
let Fetched {
bytes,
verification,
} = fetch
.fetch_bytes(&tag, &download, progress)
.map_err(|reason| fetch_error(&tag, &reason))?;
if let Some(why) = not_aarch64(&bytes) {
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"));
}
drop(download);
let mut partial = tempfile::NamedTempFile::new_in(dir)
.map_err(|error| io_error(dir, "create temporary binary in", &error))?;
partial
.as_file_mut()
.set_len(0)
.and_then(|()| partial.write_all(&bytes))
.map_err(|error| io_error(partial.path(), "write", &error))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
partial
.as_file()
.set_permissions(std::fs::Permissions::from_mode(0o755))
.map_err(|error| io_error(partial.path(), "chmod", &error))?;
}
partial
.into_temp_path()
.persist(cached)
.map_err(|error| io_error(cached, "install", &error.error))?;
let sha256 = sha256_hex(&bytes);
let record = record_path(state_dir, &version);
let mut record_partial = tempfile::NamedTempFile::new_in(dir)
.map_err(|error| io_error(dir, "create temporary record in", &error))?;
record_partial
.write_all(record_json(&version, &sha256, verification).as_bytes())
.map_err(|error| io_error(&record, "write record", &error))?;
record_partial
.into_temp_path()
.persist(&record)
.map_err(|error| io_error(&record, "record", &error.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,
})
}
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> {
let env = &crate::env::process;
resolve(request, env, &HttpsFetch::from_env(env), &mut |_| {}).map_err(Error::from)
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
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 provisioning_requires_an_absolute_default_root() {
const CHILD: &str = "MICROVMS_TEST_STATE_ROOT_CHILD";
if std::env::var_os(CHILD).is_some() {
struct Unused;
impl Fetch for Unused {
fn fetch(
&self,
_: &str,
_: &Path,
_: &mut dyn FnMut(&str),
) -> Result<Verification, String> {
panic!("an unresolved default root must not start a fetch");
}
}
let request = Request {
binary: None,
state_dir: None,
version: None,
};
let error = resolve(
&request,
&|key| matches!(key, "HOME" | "USERPROFILE").then(|| "relative-home".to_string()),
&Unused,
&mut |_| {},
)
.expect_err("relative default root");
assert!(error.message.contains("absolute user home"), "{error}");
assert!(!Path::new("relative-home").exists());
return;
}
let directory = tempfile::tempdir().expect("scratch directory");
let output = std::process::Command::new(std::env::current_exe().expect("test binary"))
.args([
"--exact",
"provision::tests::provisioning_requires_an_absolute_default_root",
])
.env(CHILD, "1")
.current_dir(directory.path())
.output()
.expect("child test");
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stdout)
);
}
#[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 v9.9.9"),
"{remedies}"
);
assert!(remedies.contains("--cert-identity https://github.com/laithalsaadoon/microvms-agentd/.github/workflows/release.yml@refs/tags/v9.9.9"), "{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"
);
}
}