fix(error): let the typed variants survive the anyhow conversion

KvmUnavailable and ArchNotAvailable were raised as anyhow context
strings and wrapped into Other at the public boundary, so the
matching lib.rs documents never fired. They now ride at the top of
an anyhow chain and the manual From recovers them by downcast;
KvmUnavailable carries the open(2) error as its source instead of
swallowing it.

This changes the API: KvmUnavailable is a struct variant.
This commit is contained in:
vhaudiquet committed 2026-10-29 22:42:21 +01:00
1 parent 56af198d54
commit 185ffeeb3d
3 files changed
+60 -17

No files matched your search

+7 -7
View File
@@ -8,7 +8,7 @@ use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::distro::{Distro, ImageSource};
use crate::Result;
use crate::{Error, Result};
/// Path of the digest sidecar file for a cached OCI image.
/// e.g. `foo.tar.gz` → `foo.tar.gz.digest`
@@ -445,16 +445,16 @@ async fn try_download_oci_image(
m_arch == arch && m_os == "linux"
})
.ok_or_else(|| {
// List available architectures in the error message
// The typed variant rides at the top of the chain, so it
// survives into `crate::Error` for consumers to match on.
let available: Vec<&str> = manifests
.iter()
.filter_map(|m| m["platform"]["architecture"].as_str())
.collect();
anyhow!(
"No manifest found for architecture '{}'. Available: {}",
arch,
available.join(", ")
)
anyhow::Error::new(Error::ArchNotAvailable {
arch: arch.to_string(),
available: available.join(", "),
})
})?;
let manifest_digest = manifest_entry["digest"]
+43 -4
View File
@@ -6,12 +6,20 @@
//! versus ordinary failures. Everything without a semantic variant
//! surfaces as [`Error::Other`], which preserves the full anyhow context
//! chain.
//!
//! Internal plumbing is anyhow all the way down; a semantic variant is
//! raised by constructing it as the top of an anyhow chain
//! (`anyhow::Error::new(Error::...)`). The [`From`] impl below recovers
//! exactly that, so the type a consumer matches on survives every `?`
//! between the producer and the public API.
/// Errors returned by the ecrou library API.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// No working KVM on this host, and ecrou has no software-emulation
/// fallback. The remediation names the usual causes in order.
/// fallback. The remediation names the usual causes in order; the
/// source carries the open(2) or ioctl error behind the probe
/// failure.
#[error(
"KVM is not available\n\n\
ecrou boots microVMs and needs /dev/kvm:\n\
@@ -21,7 +29,7 @@ pub enum Error {
\x20 cat /sys/module/kvm_intel/parameters/nested\n\
\x20 access: sudo usermod -aG kvm $USER (re-login)"
)]
KvmUnavailable,
KvmUnavailable(#[source] std::io::Error),
/// The registry publishes no linux manifest for the requested
/// architecture; `available` lists the architectures it does serve.
@@ -30,7 +38,19 @@ pub enum Error {
/// Any other failure, preserving the full context chain.
#[error(transparent)]
Other(#[from] anyhow::Error),
Other(anyhow::Error),
}
impl From<anyhow::Error> for Error {
fn from(err: anyhow::Error) -> Self {
// A semantic variant riding at the top of an anyhow chain
// travels intact; anything else wraps as Other and keeps the
// whole context chain for Display.
match err.downcast::<Error>() {
Ok(typed) => typed,
Err(rest) => Error::Other(rest),
}
}
}
/// Result alias for the public library API: `Result<T>` carries [`Error`].
@@ -42,10 +62,29 @@ mod tests {
#[test]
fn kvm_unavailable_renders_remediation() {
let msg = Error::KvmUnavailable.to_string();
let msg =
Error::KvmUnavailable(std::io::Error::from_raw_os_error(libc::EACCES)).to_string();
assert!(msg.contains("KVM is not available"));
assert!(msg.contains("modprobe kvm_intel"));
assert!(msg.contains("usermod -aG kvm"));
// The open(2) detail stays reachable for {err:#} printing,
// which the CLI does over an anyhow wrapping of this error.
let err = Error::KvmUnavailable(std::io::Error::from_raw_os_error(libc::EACCES));
let printed = format!("{:#}", anyhow::Error::new(err));
assert!(printed.contains("Permission denied"), "{printed}");
}
#[test]
fn a_typed_error_survives_the_anyhow_conversion() {
let raised =
anyhow::Error::new(Error::KvmUnavailable(std::io::Error::other("no /dev/kvm")));
match Error::from(raised) {
Error::KvmUnavailable(_) => {}
other => panic!("typed variant lost through From: {other}"),
}
// A plain anyhow error still wraps as Other with its chain kept.
let plain = anyhow::anyhow!("download failed").context("fetching the image");
assert!(matches!(Error::from(plain), Error::Other(_)));
}
#[test]
+10 -6
View File
@@ -13,7 +13,7 @@ use std::ffi::{c_char, CString};
use std::os::unix::ffi::OsStrExt;
use std::path::Path;
use anyhow::{anyhow, Context, Result};
use anyhow::{Context, Result};
use crate::Error;
@@ -304,18 +304,22 @@ pub fn kvm_probe() -> Result<()> {
.read(true)
.write(true)
.open("/dev/kvm")
.map_err(|err| anyhow::Error::from(err).context(Error::KvmUnavailable.to_string()))?;
// The variant rides at the top of the chain so it survives the
// conversion into `crate::Error`; the open(2) error stays
// reachable as its source.
.map_err(|err| anyhow::Error::new(Error::KvmUnavailable(err)))?;
// Safety: the fd is open and an ioctl on /dev/kvm with
// KVM_GET_API_VERSION reads nothing; only the kernel writes the int
// return.
let ret = unsafe { libc::ioctl(file.as_raw_fd(), KVM_GET_API_VERSION) };
if ret != KVM_API_VERSION_EXPECTED {
return Err(anyhow!(
return Err(anyhow::Error::new(Error::KvmUnavailable(
std::io::Error::other(format!(
"/dev/kvm reports API version {}, expected {}; the kernel module \
and the userspace disagree",
ret,
KVM_API_VERSION_EXPECTED
));
ret, KVM_API_VERSION_EXPECTED
)),
)));
}
Ok(())
}