use std::{
ffi::OsString,
fs::File,
io::{BufReader, BufWriter, IsTerminal as _, Read as _, Write as _},
path::{Path, PathBuf},
process::Command,
sync::OnceLock,
thread,
time::Duration,
};
use color_eyre::eyre::{Result, WrapErr, eyre};
use fs_err as fs;
use fs2::FileExt;
use futures_util::StreamExt as _;
use imago::{
FormatAccess, FormatCreateBuilder, FormatDriverBuilder, PermissiveImplicitOpenGate, Storage,
StorageCreateOptions, file::File as ImagoFile, qcow2::Qcow2,
};
use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
use sha2::{Digest, Sha256};
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
use xz2::read::XzDecoder;
use vmrunner_sysroot::{
GuestfsCopyOptions, GuestfsDiskFormat, SysrootOptions, copy_paths_from_guest,
linux::{extract_linux_sysroot, linux_sysroot_dir_name, validate_linux_sysroot},
};
pub use vmrunner_macros::test;
pub use vmrunner_test_harness as test_harness;
#[cfg(feature = "gpu")]
pub use vmrunner_test_harness::GpuConfig;
pub use vmrunner_test_harness::{
Gvproxy, HostUplink, Network, NetworkBackend, Node, RootDevice, RootFsType, RootMount,
RootMountOptions, TestCase, TunTap, Userspace, VmOutput,
};
pub const QCOW2_IMAGE_CACHE_DIR: &str = "target/vmrunner-qcow2-imag";
const GUEST_INIT_FINGERPRINT_VERSION: &str = "vmrunner-guest-init-fingerprint-v1";
const MKOSI_QCOW2_PREP_FINGERPRINT_VERSION: &str = "vmrunner-mkosi-qcow2-prep-v1";
const SHA256_HEX_LEN: usize = 64;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ExternalQCowFilesystem {
pub name: &'static str,
pub arch: &'static str,
pub url: &'static str,
pub digest: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct MkosiQcow2Prep {
image_url: &'static str,
distribution: &'static str,
release: &'static str,
architecture: &'static str,
use_default_tools_tree: bool,
packages: &'static [&'static str],
}
pub const EXTERNAL_QCOW_FILESYSTEMS: &[ExternalQCowFilesystem] = &[
ExternalQCowFilesystem {
name: "fedora",
arch: "aarch64",
url: "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Cloud/aarch64/images/Fedora-Cloud-Base-Generic-44-1.7.aarch64.qcow2",
digest: "55c60a3b80d3616a08705afd0459e75fe9f03c54aba7a46e4002a41a72fa0d5b",
},
ExternalQCowFilesystem {
name: "fedora",
arch: "x86_64",
url: "https://download.fedoraproject.org/pub/fedora/linux/releases/44/Cloud/x86_64/images/Fedora-Cloud-Base-Generic-44-1.7.x86_64.qcow2",
digest: "28680fe5b371a5a82ebf43a31926e086a168e59949d03969c5093e7071f90b7f",
},
ExternalQCowFilesystem {
name: "ubuntu",
arch: "aarch64",
url: "https://cloud-images.ubuntu.com/daily/server/releases/26.04/release-20260720/ubuntu-26.04-server-cloudimg-arm64.img",
digest: "7bcf159e29ad0000bfed9c57875908c39268f5ed1257f4958fa6a9f5f60edd54",
},
ExternalQCowFilesystem {
name: "ubuntu",
arch: "x86_64",
url: "https://cloud-images.ubuntu.com/daily/server/releases/26.04/release-20260720/ubuntu-26.04-server-cloudimg-amd64.img",
digest: "117816726abbdefc5ef3e38902e81a76f1c76c3610e709999d0885f9d5d9b477",
},
];
const FEDORA_SYSROOT_PACKAGES: &[&str] = &["glibc-devel", "glibc-static", "kernel-headers"];
const UBUNTU_SYSROOT_PACKAGES: &[&str] = &["libc6-dev"];
const MKOSI_QCOW2_PREP: &[MkosiQcow2Prep] = &[
MkosiQcow2Prep {
image_url: EXTERNAL_QCOW_FILESYSTEMS[0].url,
distribution: "fedora",
release: "44",
architecture: "arm64",
use_default_tools_tree: false,
packages: FEDORA_SYSROOT_PACKAGES,
},
MkosiQcow2Prep {
image_url: EXTERNAL_QCOW_FILESYSTEMS[1].url,
distribution: "fedora",
release: "44",
architecture: "x86-64",
use_default_tools_tree: false,
packages: FEDORA_SYSROOT_PACKAGES,
},
MkosiQcow2Prep {
image_url: EXTERNAL_QCOW_FILESYSTEMS[2].url,
distribution: "ubuntu",
release: "resolute",
architecture: "arm64",
use_default_tools_tree: true,
packages: UBUNTU_SYSROOT_PACKAGES,
},
MkosiQcow2Prep {
image_url: EXTERNAL_QCOW_FILESYSTEMS[3].url,
distribution: "ubuntu",
release: "resolute",
architecture: "x86-64",
use_default_tools_tree: true,
packages: UBUNTU_SYSROOT_PACKAGES,
},
];
pub fn qcow2_image_cache_path(image_url: &str) -> PathBuf {
PathBuf::from(QCOW2_IMAGE_CACHE_DIR).join(image_cache_file_name(image_url))
}
pub fn qcow2_image_cache_path_with_sha256(
image_url: &str,
expected_sha256: &str,
) -> Result<PathBuf> {
let digest = normalize_sha256(expected_sha256)?;
Ok(qcow2_image_cache_path_with_normalized_sha256(
image_url, &digest,
))
}
pub fn ensure_root_qcow2(root_qcow2: impl AsRef<Path>) -> Result<PathBuf> {
let root_qcow2 = root_qcow2.as_ref();
let Some(image_url) = root_qcow2.to_str().filter(|value| is_image_url(value)) else {
return decompress_xz_image_if_needed(root_qcow2);
};
let image_path = if let Some(sha256) = known_qcow2_image_sha256(image_url) {
ensure_qcow2_image_cached_with_sha256(image_url, sha256)?
} else {
ensure_qcow2_image_cached(image_url)?
};
let image_path = decompress_xz_image_if_needed(&image_path)?;
if let Some(prep) = known_mkosi_qcow2_prep(image_url) {
ensure_mkosi_prepared_qcow2(&image_path, prep)
} else {
Ok(image_path)
}
}
fn decompress_xz_image_if_needed(image_path: &Path) -> Result<PathBuf> {
if image_path
.extension()
.and_then(|extension| extension.to_str())
!= Some("xz")
{
return Ok(image_path.to_path_buf());
}
let output_path = image_path.with_extension("");
if output_path.exists() {
return Ok(output_path);
}
let lock_path = output_path.with_extension("lock");
let lock = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)
.wrap_err_with(|| format!("open image decompression lock '{}'", lock_path.display()))?;
lock.lock_exclusive().wrap_err_with(|| {
format!(
"lock image decompression '{}'; another vmrunner process may be decompressing it",
lock_path.display()
)
})?;
if output_path.exists() {
return Ok(output_path);
}
let temp_path = output_path.with_extension(format!("tmp.{}", std::process::id()));
let compressed = File::open(image_path)
.wrap_err_with(|| format!("open compressed image '{}'", image_path.display()))?;
let mut decoder = XzDecoder::new(BufReader::new(compressed));
let temp_file = File::create(&temp_path).wrap_err_with(|| {
format!(
"create temporary decompressed image '{}'",
temp_path.display()
)
})?;
let mut writer = BufWriter::new(temp_file);
std::io::copy(&mut decoder, &mut writer).wrap_err_with(|| {
format!(
"decompress image '{}' to '{}'",
image_path.display(),
temp_path.display()
)
})?;
writer.flush().wrap_err("flush decompressed image")?;
writer
.get_ref()
.sync_all()
.wrap_err("sync decompressed image")?;
fs::rename(&temp_path, &output_path).wrap_err_with(|| {
format!(
"install decompressed image '{}' to '{}'",
temp_path.display(),
output_path.display()
)
})?;
Ok(output_path)
}
pub fn ensure_qcow2_image_cached(image_url: &str) -> Result<PathBuf> {
let cache_path = qcow2_image_cache_path(image_url);
if cache_path.exists() {
return Ok(cache_path);
}
with_qcow2_image_cache_lock(&cache_path, || {
download_qcow2_image_to_cache(image_url, &cache_path)
})?;
Ok(cache_path)
}
pub fn ensure_qcow2_image_cached_with_sha256(
image_url: &str,
expected_sha256: &str,
) -> Result<PathBuf> {
let expected_sha256 = normalize_sha256(expected_sha256)?;
let cache_path = qcow2_image_cache_path_with_normalized_sha256(image_url, &expected_sha256);
let _lock = Qcow2ImageCacheLock::acquire(&cache_path)?;
if cache_path.exists() {
if verify_sha256_file(&cache_path, &expected_sha256).is_ok() {
return Ok(cache_path);
}
fs::remove_file(&cache_path)
.wrap_err_with(|| format!("remove invalid cached image '{}'", cache_path.display()))?;
}
let legacy_cache_path = qcow2_image_cache_path(image_url);
if legacy_cache_path.exists()
&& legacy_cache_path != cache_path
&& verify_sha256_file(&legacy_cache_path, &expected_sha256).is_ok()
{
copy_regular_file_atomic(&legacy_cache_path, &cache_path)?;
return Ok(cache_path);
}
download_qcow2_image_to_cache_with_sha256(image_url, &cache_path, &expected_sha256)?;
Ok(cache_path)
}
fn image_cache_file_name(image_url: &str) -> String {
let image_url_path = image_url.split(['?', '#']).next().unwrap_or(image_url);
let file_name = if image_url_path.ends_with('/') {
"image.qcow2"
} else {
image_url_path
.rsplit('/')
.find(|part| !part.is_empty())
.unwrap_or("image.qcow2")
};
let safe_file_name = String::from_iter(file_name.chars().map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '.' || ch == '-' || ch == '_' {
ch
} else {
'-'
}
}));
if safe_file_name.is_empty() || safe_file_name == "." || safe_file_name == ".." {
"image.qcow2".to_owned()
} else {
safe_file_name
}
}
fn qcow2_image_cache_path_with_normalized_sha256(image_url: &str, sha256: &str) -> PathBuf {
PathBuf::from(QCOW2_IMAGE_CACHE_DIR)
.join("sha256")
.join(sha256)
.join(image_cache_file_name(image_url))
}
fn normalize_sha256(value: &str) -> Result<String> {
let value = value.trim().strip_prefix("sha256:").unwrap_or(value.trim());
if value.len() != SHA256_HEX_LEN || !value.chars().all(|ch| ch.is_ascii_hexdigit()) {
return Err(eyre!(
"expected SHA-256 digest as {SHA256_HEX_LEN} hex characters"
));
}
Ok(value.to_ascii_lowercase())
}
fn verify_sha256_file(path: &Path, expected_sha256: &str) -> Result<()> {
let actual_sha256 = sha256_file(path)?;
if actual_sha256 == expected_sha256 {
return Ok(());
}
Err(eyre!(
"SHA-256 mismatch for '{}': expected {expected_sha256}, got {actual_sha256}",
path.display()
))
}
fn sha256_file(path: &Path) -> Result<String> {
let mut hasher = Sha256::new();
hash_file_contents(&mut hasher, path)?;
Ok(format!("{:x}", hasher.finalize()))
}
fn sha256_directory(path: &Path) -> Result<String> {
if !path.is_dir() {
return Err(eyre!(
"directory '{}' does not exist or is not a directory",
path.display()
));
}
let mut hasher = Sha256::new();
hash_sysroot_entry(&mut hasher, path, path)?;
Ok(format!("{:x}", hasher.finalize()))
}
fn hash_sysroot_entry(hasher: &mut Sha256, root: &Path, path: &Path) -> Result<()> {
let relative = path.strip_prefix(root).unwrap_or(path);
let metadata = fs::symlink_metadata(path)
.wrap_err_with(|| format!("read metadata for sysroot path '{}'", path.display()))?;
let file_type = metadata.file_type();
if file_type.is_dir() {
hash_sysroot_record(hasher, b"dir", relative, None);
let mut entries = Vec::new();
for entry in
fs::read_dir(path).wrap_err_with(|| format!("read sysroot dir '{}'", path.display()))?
{
entries.push(entry?.path());
}
entries.sort();
for entry in entries {
hash_sysroot_entry(hasher, root, &entry)?;
}
return Ok(());
}
if file_type.is_file() {
hash_sysroot_record(
hasher,
b"file",
relative,
Some(metadata.len().to_string().as_bytes()),
);
hash_file_contents(hasher, path)?;
return Ok(());
}
if file_type.is_symlink() {
let target = fs::read_link(path)
.wrap_err_with(|| format!("read sysroot symlink '{}'", path.display()))?;
hash_sysroot_record(
hasher,
b"symlink",
relative,
Some(&os_str_digest_bytes(target.as_os_str())),
);
return Ok(());
}
Err(eyre!(
"unsupported sysroot path type for '{}'",
path.display()
))
}
fn hash_file_contents(hasher: &mut Sha256, path: &Path) -> Result<()> {
let mut file = File::open(path).wrap_err_with(|| format!("open '{}'", path.display()))?;
let mut buffer = [0; 1024 * 1024];
loop {
let read = file
.read(&mut buffer)
.wrap_err_with(|| format!("read '{}'", path.display()))?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
Ok(())
}
fn hash_sysroot_record(hasher: &mut Sha256, kind: &[u8], relative: &Path, extra: Option<&[u8]>) {
hash_digest_field(hasher, kind);
hash_digest_field(hasher, &os_str_digest_bytes(relative.as_os_str()));
if let Some(extra) = extra {
hash_digest_field(hasher, extra);
}
}
fn hash_digest_field(hasher: &mut Sha256, bytes: &[u8]) {
hasher.update(bytes.len().to_le_bytes());
hasher.update(bytes);
}
#[cfg(unix)]
fn os_str_digest_bytes(value: &std::ffi::OsStr) -> Vec<u8> {
use std::os::unix::ffi::OsStrExt as _;
value.as_bytes().to_vec()
}
#[cfg(not(unix))]
fn os_str_digest_bytes(value: &std::ffi::OsStr) -> Vec<u8> {
value.to_string_lossy().as_bytes().to_vec()
}
fn known_qcow2_image_sha256(image_url: &str) -> Option<&'static str> {
EXTERNAL_QCOW_FILESYSTEMS
.iter()
.find_map(|image| (image.url == image_url).then_some(image.digest))
}
fn known_mkosi_qcow2_prep(image_url: &str) -> Option<&'static MkosiQcow2Prep> {
MKOSI_QCOW2_PREP
.iter()
.find(|prep| prep.image_url == image_url)
}
fn ensure_mkosi_prepared_qcow2(source_qcow2: &Path, prep: &MkosiQcow2Prep) -> Result<PathBuf> {
let source_sha256 = sha256_file(source_qcow2)
.wrap_err_with(|| format!("hash source qcow image '{}'", source_qcow2.display()))?;
let prep_sha256 = mkosi_qcow2_prep_sha256(prep);
let output_path = mkosi_prepared_qcow2_path(source_qcow2, prep, &source_sha256, &prep_sha256)?;
if output_path.exists() {
return Ok(output_path);
}
let _lock = Qcow2ImageCacheLock::acquire(&output_path)?;
if output_path.exists() {
return Ok(output_path);
}
run_mkosi_qcow2_prep(source_qcow2, prep, &output_path)?;
Ok(output_path)
}
fn mkosi_prepared_qcow2_path(
source_qcow2: &Path,
prep: &MkosiQcow2Prep,
source_sha256: &str,
prep_sha256: &str,
) -> Result<PathBuf> {
let source_name = source_qcow2
.file_stem()
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty())
.ok_or_else(|| {
eyre!(
"source qcow image '{}' has no usable file stem",
source_qcow2.display()
)
})?;
Ok(path_parent_or_current(source_qcow2)
.join("mkosi")
.join(prep.distribution)
.join(prep.architecture)
.join("sha256")
.join(source_sha256)
.join("prep-sha256")
.join(prep_sha256)
.join(format!("{source_name}.mkosi.qcow2")))
}
fn mkosi_qcow2_prep_sha256(prep: &MkosiQcow2Prep) -> String {
let mut hasher = Sha256::new();
hash_digest_field(&mut hasher, MKOSI_QCOW2_PREP_FINGERPRINT_VERSION.as_bytes());
hash_digest_field(&mut hasher, prep.image_url.as_bytes());
hash_digest_field(&mut hasher, prep.distribution.as_bytes());
hash_digest_field(&mut hasher, prep.release.as_bytes());
hash_digest_field(&mut hasher, prep.architecture.as_bytes());
hash_digest_field(
&mut hasher,
if prep.use_default_tools_tree {
b"default-tools-tree"
} else {
b"host-tools-tree"
},
);
hash_digest_field(&mut hasher, prep.packages.len().to_string().as_bytes());
for package in prep.packages {
hash_digest_field(&mut hasher, package.as_bytes());
}
format!("{:x}", hasher.finalize())
}
fn run_mkosi_qcow2_prep(
source_qcow2: &Path,
prep: &MkosiQcow2Prep,
output_path: &Path,
) -> Result<()> {
let output_dir = path_parent_or_current(output_path);
fs::create_dir_all(&output_dir).wrap_err_with(|| {
format!(
"create mkosi prepared qcow output directory '{}'",
output_dir.display()
)
})?;
let output_name = output_path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| {
eyre!(
"mkosi output path '{}' has no file name",
output_path.display()
)
})?;
let output_stem = output_name.strip_suffix(".qcow2").unwrap_or(output_name);
let output_raw = output_dir.join(format!("{output_stem}.raw"));
let temp_dir = tempfile::Builder::new()
.prefix(".mkosi-qcow2-prep.")
.tempdir_in(&output_dir)
.wrap_err_with(|| {
format!(
"create temporary mkosi qcow preparation directory under '{}'",
output_dir.display()
)
})?;
let base_tree = temp_dir.path().join("base-tree");
copy_qcow2_root_to_mkosi_base_tree(source_qcow2, &base_tree)?;
let mut command = Command::new("mkosi");
command
.arg("--force")
.arg("--distribution")
.arg(prep.distribution)
.arg("--release")
.arg(prep.release)
.arg("--architecture")
.arg(prep.architecture)
.arg("--repository-key-fetch=yes")
.arg("--format")
.arg("disk")
.arg("--output-directory")
.arg(&output_dir)
.arg("--output")
.arg(output_stem)
.arg("--output-extension")
.arg("raw")
.arg("--base-tree")
.arg(&base_tree)
.arg("--clean-package-metadata=no")
.arg("--with-docs=no");
if prep.use_default_tools_tree {
command.arg("--tools-tree").arg("default");
}
for package in prep.packages {
command.arg("--package").arg(package);
}
command.arg("build");
let command_debug = format!("{command:?}");
let output = command
.output()
.wrap_err_with(|| format!("spawn mkosi to prepare root image: {command_debug}"))?;
if !output.status.success() {
return Err(eyre!(
"mkosi failed while preparing root image '{}' from '{}': {command_debug}\nstatus: {}\nstdout:\n{}\nstderr:\n{}",
output_path.display(),
source_qcow2.display(),
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
));
}
if !output_raw.is_file() {
return Err(eyre!(
"mkosi completed but did not create prepared raw image '{}'",
output_raw.display()
));
}
convert_raw_image_to_qcow2(&output_raw, output_path)?;
fs::remove_file(&output_raw).wrap_err_with(|| {
format!(
"remove temporary mkosi raw image '{}'",
output_raw.display()
)
})?;
Ok(())
}
fn copy_qcow2_root_to_mkosi_base_tree(source_qcow2: &Path, base_tree: &Path) -> Result<()> {
fs::create_dir_all(base_tree)
.wrap_err_with(|| format!("create mkosi base tree '{}'", base_tree.display()))?;
let paths = ["/usr", "/etc", "/var", "/root", "/boot"];
copy_paths_from_guest(
GuestfsCopyOptions::new(source_qcow2, paths)
.with_disk_format(GuestfsDiskFormat::Named("qcow2".to_owned())),
base_tree,
)
.wrap_err_with(|| {
format!(
"copy root filesystem content from '{}' into mkosi base tree '{}'",
source_qcow2.display(),
base_tree.display()
)
})?;
ensure_mkosi_base_tree_usrmerge_symlinks(base_tree)?;
if base_tree.join("usr").is_dir() && base_tree.join("etc").exists() {
return Ok(());
}
Err(eyre!(
"libguestfs root copy from '{}' did not produce a usable mkosi base tree at '{}'",
source_qcow2.display(),
base_tree.display()
))
}
fn ensure_mkosi_base_tree_usrmerge_symlinks(base_tree: &Path) -> Result<()> {
for (link, target) in [
("bin", "usr/bin"),
("sbin", "usr/sbin"),
("lib", "usr/lib"),
("lib64", "usr/lib64"),
] {
let link_path = base_tree.join(link);
if fs::symlink_metadata(&link_path).is_ok() {
continue;
}
if !base_tree.join(target).exists() {
continue;
}
create_relative_symlink(target, &link_path).wrap_err_with(|| {
format!(
"create mkosi base tree compatibility symlink '{} -> {}'",
link_path.display(),
target
)
})?;
}
Ok(())
}
#[cfg(unix)]
fn create_relative_symlink(target: &str, link_path: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(target, link_path)
}
#[cfg(not(unix))]
fn create_relative_symlink(_target: &str, link_path: &Path) -> std::io::Result<()> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
format!(
"mkosi base tree symlinks require a Unix host, got '{}'",
link_path.display()
),
))
}
fn convert_raw_image_to_qcow2(raw_path: &Path, qcow2_path: &Path) -> Result<()> {
if qcow2_path.exists() {
fs::remove_file(qcow2_path)
.wrap_err_with(|| format!("remove stale qcow image '{}'", qcow2_path.display()))?;
}
let temp_path = qcow2_path.with_file_name(format!(
".{}.tmp.{}",
qcow2_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("mkosi.qcow2"),
std::process::id()
));
if temp_path.exists() {
fs::remove_file(&temp_path).wrap_err_with(|| {
format!(
"remove stale temporary qcow image '{}'",
temp_path.display()
)
})?;
}
convert_raw_image_to_qcow2_with_imago(raw_path.to_path_buf(), temp_path.clone())?;
fs::rename(&temp_path, qcow2_path).wrap_err_with(|| {
format!(
"install converted qcow image '{}' to '{}'",
temp_path.display(),
qcow2_path.display()
)
})?;
Ok(())
}
fn convert_raw_image_to_qcow2_with_imago(raw_path: PathBuf, qcow2_path: PathBuf) -> Result<()> {
let conversion_thread = thread::Builder::new()
.name("vmrunner-raw-to-qcow2".to_owned())
.spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.wrap_err("create Tokio runtime for raw to qcow2 conversion")?;
runtime.block_on(convert_raw_image_to_qcow2_async(raw_path, qcow2_path))
})
.wrap_err("spawn raw to qcow2 conversion thread")?;
conversion_thread
.join()
.map_err(|_| eyre!("raw to qcow2 conversion thread panicked"))?
}
async fn convert_raw_image_to_qcow2_async(raw_path: PathBuf, qcow2_path: PathBuf) -> Result<()> {
let raw_size = fs::metadata(&raw_path)
.wrap_err_with(|| format!("read raw image metadata '{}'", raw_path.display()))?
.len();
let qcow2_storage = ImagoFile::create_open(StorageCreateOptions::new().filename(&qcow2_path))
.await
.wrap_err_with(|| format!("create qcow image storage '{}'", qcow2_path.display()))?;
Qcow2::<ImagoFile>::create_builder(qcow2_storage)
.size(raw_size)
.create()
.await
.wrap_err_with(|| format!("create qcow image '{}'", qcow2_path.display()))?;
let qcow2 = Qcow2::<ImagoFile>::builder_path(&qcow2_path)
.write(true)
.open(PermissiveImplicitOpenGate::default())
.await
.wrap_err_with(|| format!("open qcow image '{}' for writing", qcow2_path.display()))?;
let qcow2 = FormatAccess::new(qcow2);
let mut raw = tokio::fs::File::open(&raw_path)
.await
.wrap_err_with(|| format!("open raw image '{}'", raw_path.display()))?;
let mut buffer = vec![0; 1024 * 1024];
let mut offset = 0;
loop {
let read = raw
.read(&mut buffer)
.await
.wrap_err_with(|| format!("read raw image '{}'", raw_path.display()))?;
if read == 0 {
break;
}
qcow2
.write(&buffer[..read], offset)
.await
.wrap_err_with(|| format!("write qcow image '{}'", qcow2_path.display()))?;
offset += read as u64;
}
if offset != raw_size {
return Err(eyre!(
"raw image '{}' changed while converting: expected {raw_size} bytes, read {offset} bytes",
raw_path.display()
));
}
qcow2
.flush()
.await
.wrap_err_with(|| format!("flush qcow image '{}'", qcow2_path.display()))?;
Ok(())
}
fn is_image_url(value: &str) -> bool {
value.starts_with("https://") || value.starts_with("http://")
}
struct Qcow2ImageCacheLock {
_file: std::fs::File,
}
impl Qcow2ImageCacheLock {
fn acquire(cache_path: &Path) -> Result<Self> {
let lock_path = qcow2_image_cache_lock_path(cache_path);
let lock_dir = path_parent_or_current(&lock_path);
fs::create_dir_all(&lock_dir).wrap_err_with(|| {
format!("create qcow2 image cache lock dir '{}'", lock_dir.display())
})?;
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)
.wrap_err_with(|| format!("open qcow2 image cache lock '{}'", lock_path.display()))?;
file.lock_exclusive().wrap_err_with(|| {
format!(
"lock qcow2 image cache '{}'; another vmrunner process may be downloading it",
lock_path.display()
)
})?;
Ok(Self { _file: file })
}
}
fn with_qcow2_image_cache_lock(
cache_path: &Path,
download: impl FnOnce() -> Result<()>,
) -> Result<bool> {
let _lock = Qcow2ImageCacheLock::acquire(cache_path)?;
if cache_path.exists() {
return Ok(false);
}
download()?;
Ok(true)
}
fn qcow2_image_cache_lock_path(cache_path: &Path) -> PathBuf {
let mut lock_name = OsString::from(".");
if let Some(file_name) = cache_path.file_name() {
lock_name.push(file_name);
} else {
lock_name.push("qcow2-image");
}
lock_name.push(".lock");
path_parent_or_current(cache_path).join(lock_name)
}
fn download_qcow2_image_to_cache(image_url: &str, cache_path: &Path) -> Result<()> {
let temp_file = download_qcow2_image_to_temp(image_url, cache_path)?;
persist_downloaded_qcow2_image(temp_file, cache_path)
}
fn download_qcow2_image_to_cache_with_sha256(
image_url: &str,
cache_path: &Path,
expected_sha256: &str,
) -> Result<()> {
let temp_file = download_qcow2_image_to_temp(image_url, cache_path)?;
verify_sha256_file(temp_file.path(), expected_sha256)?;
persist_downloaded_qcow2_image(temp_file, cache_path)
}
fn download_qcow2_image_to_temp(
image_url: &str,
cache_path: &Path,
) -> Result<tempfile::NamedTempFile> {
let cache_dir = path_parent_or_current(cache_path);
fs::create_dir_all(&cache_dir)
.wrap_err_with(|| format!("create qcow2 image cache dir '{}'", cache_dir.display()))?;
let temp_file = tempfile::Builder::new()
.prefix(".download-")
.tempfile_in(&cache_dir)
.wrap_err_with(|| {
format!(
"create temporary qcow2 image under '{}'",
cache_dir.display()
)
})?;
download_qcow2_image_with_tokio(
image_url.to_owned(),
cache_path.to_path_buf(),
temp_file.path().to_path_buf(),
)?;
temp_file.as_file().sync_all().wrap_err_with(|| {
format!(
"sync temporary qcow2 image '{}'",
temp_file.path().display()
)
})?;
Ok(temp_file)
}
fn download_qcow2_image_with_tokio(
image_url: String,
cache_path: PathBuf,
temp_path: PathBuf,
) -> Result<()> {
let download_thread = thread::Builder::new()
.name("vmrunner-qcow2-download".to_owned())
.spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.wrap_err("create Tokio runtime for qcow2 download")?;
runtime.block_on(download_qcow2_image(image_url, cache_path, temp_path))
})
.wrap_err("spawn qcow2 download thread")?;
download_thread
.join()
.map_err(|_| eyre!("qcow2 download thread panicked"))?
}
async fn download_qcow2_image(
image_url: String,
cache_path: PathBuf,
temp_path: PathBuf,
) -> Result<()> {
let client = reqwest::Client::builder()
.user_agent(concat!("vmrunner/", env!("CARGO_PKG_VERSION")))
.build()
.wrap_err("create HTTP client for qcow2 download")?;
let response = client
.get(&image_url)
.send()
.await
.wrap_err_with(|| format!("request qcow2 image '{image_url}'"))?
.error_for_status()
.wrap_err_with(|| format!("download qcow2 image '{image_url}'"))?;
let total_size = response.content_length();
let progress = qcow2_download_progress_bar(&image_url, &cache_path, total_size);
let download = async {
let mut output = tokio::fs::OpenOptions::new()
.write(true)
.truncate(true)
.open(&temp_path)
.await
.wrap_err_with(|| {
format!(
"open temporary qcow2 image '{}' for download",
temp_path.display()
)
})?;
let mut downloaded = 0;
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.wrap_err_with(|| format!("read qcow2 image response '{image_url}'"))?;
output.write_all(&chunk).await.wrap_err_with(|| {
format!("write temporary qcow2 image '{}'", temp_path.display())
})?;
let chunk_size = chunk.len() as u64;
downloaded += chunk_size;
progress.inc(chunk_size);
}
output
.flush()
.await
.wrap_err_with(|| format!("flush temporary qcow2 image '{}'", temp_path.display()))?;
output
.sync_all()
.await
.wrap_err_with(|| format!("sync temporary qcow2 image '{}'", temp_path.display()))?;
if let Some(total_size) = total_size {
if downloaded != total_size {
return Err(eyre!(
"downloaded qcow2 image '{image_url}' was {downloaded} bytes, expected {total_size} bytes"
));
}
}
Ok(())
}
.await;
progress.finish_and_clear();
download
}
fn qcow2_download_progress_bar(
image_url: &str,
cache_path: &Path,
total_size: Option<u64>,
) -> ProgressBar {
static QCOW2_DOWNLOAD_PROGRESS: OnceLock<MultiProgress> = OnceLock::new();
let file_name = cache_path
.file_name()
.and_then(|file_name| file_name.to_str())
.map(ToOwned::to_owned)
.unwrap_or_else(|| image_cache_file_name(image_url));
let is_spinner = total_size.is_none();
let progress = match total_size {
Some(total_size) => {
let progress = ProgressBar::new(total_size);
let style = ProgressStyle::with_template(
"{spinner} {msg} [{elapsed_precise}] [{wide_bar}] {bytes}/{total_bytes} ({bytes_per_sec}, eta {eta})",
)
.unwrap_or_else(|_| ProgressStyle::default_bar())
.progress_chars("=>-");
progress.set_style(style);
progress
}
None => {
let progress = ProgressBar::new_spinner();
let style = ProgressStyle::with_template(
"{spinner} {msg} [{elapsed_precise}] {bytes} ({bytes_per_sec})",
)
.unwrap_or_else(|_| ProgressStyle::default_spinner());
progress.set_style(style);
progress
}
};
let progress = QCOW2_DOWNLOAD_PROGRESS
.get_or_init(|| {
let target = if cfg!(test) || !std::io::stderr().is_terminal() {
ProgressDrawTarget::hidden()
} else {
ProgressDrawTarget::stderr_with_hz(10)
};
MultiProgress::with_draw_target(target)
})
.add(progress);
progress.set_message(format!("downloading {file_name}"));
if is_spinner {
progress.enable_steady_tick(Duration::from_millis(100));
}
progress
}
fn persist_downloaded_qcow2_image(
temp_file: tempfile::NamedTempFile,
cache_path: &Path,
) -> Result<()> {
if cache_path.exists() {
return Ok(());
}
temp_file
.persist(cache_path)
.map_err(|error| error.error)
.wrap_err_with(|| {
format!(
"move downloaded qcow2 image into '{}'",
cache_path.display()
)
})?;
Ok(())
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TestSetup {
test_name: String,
root_qcow2_path: PathBuf,
guest_init_path: PathBuf,
}
impl TestSetup {
pub fn new_with_root_qcow2(
test_name: impl Into<String>,
root_qcow2_path: impl Into<PathBuf>,
) -> Self {
let root_qcow2_path = root_qcow2_path.into();
let guest_init_path = guest_init_path_for_root_qcow2(&root_qcow2_path);
Self {
test_name: test_name.into(),
root_qcow2_path,
guest_init_path,
}
}
pub fn test_name(&self) -> &str {
&self.test_name
}
pub fn root_qcow2_path(&self) -> &Path {
&self.root_qcow2_path
}
pub fn guest_init_path(&self) -> &Path {
&self.guest_init_path
}
}
#[derive(Clone, Debug, Default)]
pub struct GuestInitBuildOptions {
guest_target: Option<String>,
libkrun_dir: Option<PathBuf>,
cc_linux: Option<String>,
cargo_target_dir: Option<PathBuf>,
cargo_toolchain: Option<OsString>,
}
impl GuestInitBuildOptions {
pub fn guest_target(mut self, guest_target: impl Into<String>) -> Self {
self.guest_target = Some(guest_target.into());
self
}
pub fn libkrun_dir(mut self, libkrun_dir: impl Into<PathBuf>) -> Self {
self.libkrun_dir = Some(libkrun_dir.into());
self
}
pub fn cc_linux(mut self, cc_linux: impl Into<String>) -> Self {
self.cc_linux = Some(cc_linux.into());
self
}
pub fn cargo_target_dir(mut self, cargo_target_dir: impl Into<PathBuf>) -> Self {
self.cargo_target_dir = Some(cargo_target_dir.into());
self
}
pub fn cargo_toolchain(mut self, cargo_toolchain: impl Into<OsString>) -> Self {
self.cargo_toolchain = Some(cargo_toolchain.into());
self
}
}
pub fn guest_init_path_for_root_qcow2(root_qcow2_path: impl AsRef<Path>) -> PathBuf {
let root_qcow2_path = root_qcow2_path.as_ref();
let file_name = root_qcow2_path
.file_name()
.map(OsString::from)
.unwrap_or_else(|| OsString::from("root.qcow2"));
let mut sidecar_name = OsString::from(".");
sidecar_name.push(file_name);
sidecar_name.push(".init.krun");
path_parent_or_current(root_qcow2_path).join(sidecar_name)
}
pub fn ensure_guest_init(
root_qcow2_path: impl AsRef<Path>,
guest_target: Option<&str>,
libkrun_dir: Option<&str>,
) -> Result<()> {
let mut options = GuestInitBuildOptions::default();
if let Some(guest_target) = guest_target {
options = options.guest_target(guest_target);
}
if let Some(libkrun_dir) = libkrun_dir {
options = options.libkrun_dir(libkrun_dir);
}
ensure_guest_init_with_options(root_qcow2_path, options)
}
pub fn ensure_guest_init_with_options(
root_qcow2_path: impl AsRef<Path>,
options: GuestInitBuildOptions,
) -> Result<()> {
let root_qcow2_path = root_qcow2_path.as_ref();
let init_path = guest_init_path_for_root_qcow2(root_qcow2_path);
let target_dir_parent = root_qcow2_path
.parent()
.filter(|path| !path.as_os_str().is_empty())
.map(Path::to_path_buf)
.unwrap_or_else(|| std::env::temp_dir().join("vmrunner-krun-init"));
build_and_install_guest_init(
&init_path,
&target_dir_parent,
Some(root_qcow2_path),
options,
)
}
pub fn install_guest_init_blob(
output_path: impl AsRef<Path>,
options: GuestInitBuildOptions,
) -> Result<()> {
let output_path = output_path.as_ref();
let output_dir = path_parent_or_current(output_path);
fs::create_dir_all(&output_dir)
.wrap_err_with(|| format!("create guest init output dir '{}'", output_dir.display()))?;
if output_path.exists() {
return Ok(());
}
build_and_install_guest_init(output_path, &output_dir, None, options)
}
fn selected_guest_init_target(options: &GuestInitBuildOptions) -> Option<String> {
options
.guest_target
.clone()
.or_else(|| std::env::var("KRUN_INIT_GUEST_TARGET").ok())
}
fn build_and_install_guest_init(
output_path: &Path,
target_dir_parent: &Path,
root_qcow2_path: Option<&Path>,
options: GuestInitBuildOptions,
) -> Result<()> {
if output_path.exists() && root_qcow2_path.is_none() {
return Ok(());
}
with_guest_init_artifact_lock(output_path, || {
let build =
resolve_guest_init_build(target_dir_parent, root_qcow2_path, options, output_path)?;
if guest_init_artifact_is_current(
output_path,
build.qcow_sha256.as_deref(),
&build.sysroot_sha256,
)? {
return Ok(false);
}
let _build_lock =
GuestInitBuildTargetLock::acquire(&build.cargo_target_dir, &build.guest_target)?;
reset_guest_init_build_output(&build)?;
let built_init = run_guest_init_build(&build)?;
let fingerprint = build
.qcow_sha256
.as_ref()
.map(|qcow_sha256| {
let binary_sha256 = sha256_file(&built_init).wrap_err_with(|| {
format!("hash guest init binary '{}'", built_init.display())
})?;
Ok::<_, color_eyre::Report>(GuestInitFingerprint::new(
binary_sha256,
qcow_sha256.clone(),
build.sysroot_sha256.clone(),
))
})
.transpose()?;
install_krun_init_blob_output(&built_init, output_path, fingerprint.as_ref())?;
Ok(true)
})?;
Ok(())
}
struct GuestInitArtifactLock {
_file: std::fs::File,
}
struct GuestInitBuildTargetLock {
_file: std::fs::File,
}
impl GuestInitArtifactLock {
fn acquire(output_path: &Path) -> Result<Self> {
let lock_path = guest_init_artifact_lock_path(output_path);
let lock_dir = path_parent_or_current(&lock_path);
fs::create_dir_all(&lock_dir).wrap_err_with(|| {
format!(
"create guest init artifact lock dir '{}'",
lock_dir.display()
)
})?;
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)
.wrap_err_with(|| format!("open guest init artifact lock '{}'", lock_path.display()))?;
file.lock_exclusive().wrap_err_with(|| {
format!(
"lock guest init artifact '{}'; another vmrunner process may be building it",
lock_path.display()
)
})?;
Ok(Self { _file: file })
}
}
fn with_guest_init_artifact_lock(
output_path: &Path,
action: impl FnOnce() -> Result<bool>,
) -> Result<bool> {
let _lock = GuestInitArtifactLock::acquire(output_path)?;
action()
}
impl GuestInitBuildTargetLock {
fn acquire(cargo_target_dir: &Path, guest_target: &str) -> Result<Self> {
let lock_path = guest_init_build_target_lock_path(cargo_target_dir, guest_target);
let lock_dir = path_parent_or_current(&lock_path);
fs::create_dir_all(&lock_dir).wrap_err_with(|| {
format!(
"create guest init build target lock dir '{}'",
lock_dir.display()
)
})?;
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)
.wrap_err_with(|| {
format!(
"open guest init build target lock '{}'",
lock_path.display()
)
})?;
file.lock_exclusive().wrap_err_with(|| {
format!(
"lock guest init build target '{}'; another vmrunner process may be rebuilding it",
lock_path.display()
)
})?;
Ok(Self { _file: file })
}
}
fn guest_init_artifact_lock_path(output_path: &Path) -> PathBuf {
let mut lock_name = OsString::from(".");
if let Some(file_name) = output_path.file_name() {
lock_name.push(file_name);
} else {
lock_name.push("guest-init");
}
lock_name.push(".lock");
path_parent_or_current(output_path).join(lock_name)
}
fn guest_init_build_target_lock_path(cargo_target_dir: &Path, guest_target: &str) -> PathBuf {
cargo_target_dir.join(format!(".{}.lock", safe_guest_target_name(guest_target)))
}
fn guest_init_fingerprint_path(init_path: &Path) -> PathBuf {
let mut fingerprint_name = init_path
.file_name()
.map(OsString::from)
.unwrap_or_else(|| OsString::from("init.krun"));
fingerprint_name.push(".fingerprint");
path_parent_or_current(init_path).join(fingerprint_name)
}
#[derive(Debug, Eq, PartialEq)]
struct GuestInitFingerprint {
fingerprint: String,
binary_sha256: String,
qcow_sha256: String,
sysroot_sha256: String,
}
impl GuestInitFingerprint {
#[cfg(test)]
fn from_paths(binary: &Path, qcow: &Path, sysroot: &Path) -> Result<Self> {
let binary_sha256 = sha256_file(binary)
.wrap_err_with(|| format!("hash guest init binary '{}'", binary.display()))?;
let qcow_sha256 =
sha256_file(qcow).wrap_err_with(|| format!("hash qcow image '{}'", qcow.display()))?;
let sysroot_sha256 = sha256_directory(sysroot)
.wrap_err_with(|| format!("hash sysroot '{}'", sysroot.display()))?;
Ok(Self::new(binary_sha256, qcow_sha256, sysroot_sha256))
}
fn new(binary_sha256: String, qcow_sha256: String, sysroot_sha256: String) -> Self {
let mut hasher = Sha256::new();
hash_digest_field(&mut hasher, GUEST_INIT_FINGERPRINT_VERSION.as_bytes());
hash_digest_field(&mut hasher, b"binary");
hash_digest_field(&mut hasher, binary_sha256.as_bytes());
hash_digest_field(&mut hasher, b"qcow");
hash_digest_field(&mut hasher, qcow_sha256.as_bytes());
hash_digest_field(&mut hasher, b"sysroot");
hash_digest_field(&mut hasher, sysroot_sha256.as_bytes());
let fingerprint = format!("{:x}", hasher.finalize());
Self {
fingerprint,
binary_sha256,
qcow_sha256,
sysroot_sha256,
}
}
fn to_metadata(&self) -> String {
format!(
"version={GUEST_INIT_FINGERPRINT_VERSION}\nfingerprint={}\nbinary={}\nqcow={}\nsysroot={}\n",
self.fingerprint, self.binary_sha256, self.qcow_sha256, self.sysroot_sha256
)
}
}
fn guest_init_artifact_is_current(
init_path: &Path,
qcow_sha256: Option<&str>,
sysroot_sha256: &str,
) -> Result<bool> {
if !init_path.exists() {
return Ok(false);
}
let Some(qcow_sha256) = qcow_sha256 else {
return Ok(true);
};
let fingerprint_path = guest_init_fingerprint_path(init_path);
if !fingerprint_path.is_file() {
return Ok(false);
}
let binary_sha256 = sha256_file(init_path)
.wrap_err_with(|| format!("hash guest init binary '{}'", init_path.display()))?;
let expected = GuestInitFingerprint::new(
binary_sha256,
qcow_sha256.to_owned(),
sysroot_sha256.to_owned(),
);
guest_init_fingerprint_matches(&fingerprint_path, &expected)
}
fn guest_init_fingerprint_matches(
fingerprint_path: &Path,
expected: &GuestInitFingerprint,
) -> Result<bool> {
let metadata = match fs::read(fingerprint_path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => {
return Err(error).wrap_err_with(|| {
format!(
"read guest init fingerprint '{}'",
fingerprint_path.display()
)
});
}
};
Ok(metadata == expected.to_metadata().as_bytes())
}
fn write_guest_init_fingerprint(
fingerprint_path: &Path,
fingerprint: &GuestInitFingerprint,
) -> Result<()> {
let fingerprint_dir = path_parent_or_current(fingerprint_path);
let mut temp_file = tempfile::Builder::new()
.prefix(".init.krun-fingerprint-")
.tempfile_in(&fingerprint_dir)
.wrap_err_with(|| {
format!(
"create temporary guest init fingerprint under '{}'",
fingerprint_dir.display()
)
})?;
temp_file
.write_all(fingerprint.to_metadata().as_bytes())
.wrap_err_with(|| {
format!(
"write temporary guest init fingerprint '{}'",
temp_file.path().display()
)
})?;
temp_file.as_file_mut().sync_all().wrap_err_with(|| {
format!(
"sync temporary guest init fingerprint '{}'",
temp_file.path().display()
)
})?;
fs::rename(temp_file.path(), fingerprint_path).wrap_err_with(|| {
format!(
"install guest init fingerprint into '{}'",
fingerprint_path.display()
)
})
}
#[derive(Debug)]
struct ResolvedGuestInitBuild {
guest_target: String,
source: GuestInitBuildSource,
cargo_target_dir: PathBuf,
cargo_toolchain: Option<OsString>,
cc_linux: String,
qcow_sha256: Option<String>,
sysroot_sha256: String,
}
#[derive(Debug)]
enum GuestInitBuildSource {
Checkout {
libkrun_dir: PathBuf,
manifest: PathBuf,
},
CratesIo {
manifest: PathBuf,
},
}
fn resolve_guest_init_build(
target_dir_parent: &Path,
root_qcow2_path: Option<&Path>,
options: GuestInitBuildOptions,
output_path: &Path,
) -> Result<ResolvedGuestInitBuild> {
let guest_target = selected_guest_init_target(&options).ok_or_else(|| {
eyre!(
"missing guest init target for '{}'; set `guest_target = \"...\"` on the vmrunner test or KRUN_INIT_GUEST_TARGET",
output_path.display()
)
})?;
let cargo_toolchain = options
.cargo_toolchain
.or_else(|| std::env::var_os("KRUN_INIT_CARGO_TOOLCHAIN"));
validate_guest_init_target(&guest_target, cargo_toolchain.as_ref())?;
let source = resolve_guest_init_build_source(target_dir_parent, options.libkrun_dir)?;
let qcow_sha256 = root_qcow2_path
.map(|path| {
sha256_file(path).wrap_err_with(|| format!("hash qcow image '{}'", path.display()))
})
.transpose()?;
let sysroot = canonicalize_sysroot(ensure_linux_sysroot_for_guest_target(
&guest_target,
root_qcow2_path,
qcow_sha256.as_deref(),
)?)?;
let sysroot_sha256 = sha256_directory(&sysroot)
.wrap_err_with(|| format!("hash sysroot '{}'", sysroot.display()))?;
let guest_target = guest_init_cargo_target(&guest_target);
let cc_linux = options
.cc_linux
.or_else(|| std::env::var("CC_LINUX").ok())
.or_else(|| std::env::var("CC").ok())
.unwrap_or_else(|| default_guest_init_cc(&guest_target));
let cc_linux = normalize_linux_guest_init_cc(&cc_linux, &guest_target, &sysroot)?;
let cargo_target_dir =
guest_init_target_dir(target_dir_parent, &guest_target, options.cargo_target_dir)?;
Ok(ResolvedGuestInitBuild {
guest_target,
source,
cargo_target_dir,
cargo_toolchain,
cc_linux,
qcow_sha256,
sysroot_sha256,
})
}
fn guest_init_cargo_target(guest_target: &str) -> String {
guest_target.to_owned()
}
fn default_guest_init_cc(guest_target: &str) -> String {
match guest_init_target_os(guest_target) {
Some("linux") => format!("clang -target {guest_target}"),
_ => "cc".to_owned(),
}
}
fn default_sysroot_parent() -> PathBuf {
PathBuf::from("target/vmrunner-sysroot")
}
fn default_sysroot_output_parent(root_qcow2_sha256: Option<&str>) -> PathBuf {
let parent = default_sysroot_parent();
match root_qcow2_sha256 {
Some(root_qcow2_sha256) => parent.join("sha256").join(root_qcow2_sha256),
None => parent,
}
}
fn canonicalize_sysroot(sysroot: PathBuf) -> Result<PathBuf> {
fs::canonicalize(&sysroot)
.wrap_err_with(|| format!("canonicalize sysroot '{}'", sysroot.display()))
}
fn ensure_linux_sysroot_for_guest_target(
guest_target: &str,
root_qcow2_path: Option<&Path>,
root_qcow2_sha256: Option<&str>,
) -> Result<PathBuf> {
let sysroot_dir = linux_sysroot_dir_name(guest_target)?;
if let Some(path) = std::env::var_os("VMRUNNER_LINUX_SYSROOT").map(PathBuf::from) {
return first_valid_linux_sysroot([path.join(&sysroot_dir), path]).ok_or_else(|| {
eyre!(
"VMRUNNER_LINUX_SYSROOT does not contain a complete Linux sysroot for '{guest_target}'"
)
});
}
let default_parent = default_sysroot_output_parent(root_qcow2_sha256);
let default_sysroot = default_parent.join(&sysroot_dir);
if first_valid_linux_sysroot([default_sysroot.clone()]).is_some() {
return Ok(default_sysroot);
}
let Some(root_qcow2_path) = root_qcow2_path else {
return Err(eyre!(
"missing Linux sysroot for '{guest_target}'; set VMRUNNER_LINUX_SYSROOT or build through --root-qcow2 so vmrunner can extract one from the root image"
));
};
extract_linux_sysroot(SysrootOptions::new(
root_qcow2_path,
guest_target,
default_parent,
))
}
fn first_valid_linux_sysroot(candidates: impl IntoIterator<Item = PathBuf>) -> Option<PathBuf> {
candidates
.into_iter()
.find(|path| validate_linux_sysroot(path).is_ok())
}
fn normalize_linux_guest_init_cc(
cc_value: &str,
guest_target: &str,
sysroot: &Path,
) -> Result<String> {
normalize_sysroot_guest_init_cc("Linux", cc_value, guest_target, sysroot)
}
fn normalize_sysroot_guest_init_cc(
kind: &str,
cc_value: &str,
guest_target: &str,
sysroot: &Path,
) -> Result<String> {
let mut parts = Vec::from_iter(cc_value.split_ascii_whitespace().map(str::to_owned));
if parts.is_empty() {
return Err(eyre!(
"{kind} guest init C compiler command must not be empty"
));
}
if is_zig_cc_command(&parts) {
return Err(eyre!(
"{kind} guest init now builds against an extracted sysroot; use clang/cc, not zig cc"
));
}
if is_clang_command(&parts) {
ensure_target_arg(&mut parts, guest_target);
}
ensure_sysroot_arg(&mut parts, sysroot);
Ok(parts.join(" "))
}
fn ensure_target_arg(parts: &mut Vec<String>, guest_target: &str) {
let mut index = 0;
while index < parts.len() {
match parts[index].as_str() {
"-target" | "--target" => {
parts[index] = "-target".to_owned();
if let Some(target) = parts.get_mut(index + 1) {
*target = guest_target.to_owned();
} else {
parts.push(guest_target.to_owned());
}
return;
}
target_arg if target_arg.starts_with("-target=") => {
parts[index] = format!("-target={guest_target}");
return;
}
target_arg if target_arg.starts_with("--target=") => {
parts[index] = "-target".to_owned();
parts.insert(index + 1, guest_target.to_owned());
return;
}
_ => index += 1,
}
}
parts.insert(1, "-target".to_owned());
parts.insert(2, guest_target.to_owned());
}
fn ensure_sysroot_arg(parts: &mut Vec<String>, sysroot: &Path) {
if parts
.iter()
.any(|part| part == "--sysroot" || part.starts_with("--sysroot="))
{
return;
}
parts.push("--sysroot".to_owned());
parts.push(sysroot.display().to_string());
}
fn is_clang_command(parts: &[String]) -> bool {
parts
.first()
.and_then(|command| Path::new(command).file_name())
.and_then(|file_name| file_name.to_str())
.is_some_and(|file_name| file_name == "clang" || file_name.starts_with("clang-"))
}
fn is_zig_cc_command(parts: &[String]) -> bool {
parts
.first()
.and_then(|command| Path::new(command).file_name())
.and_then(|file_name| file_name.to_str())
== Some("zig")
&& parts.get(1).is_some_and(|subcommand| subcommand == "cc")
}
fn resolve_guest_init_build_source(
target_dir_parent: &Path,
explicit_libkrun_dir: Option<PathBuf>,
) -> Result<GuestInitBuildSource> {
if let Some(libkrun_dir) =
explicit_libkrun_dir.or_else(|| std::env::var_os("LIBKRUN_DIR").map(PathBuf::from))
{
let manifest = libkrun_dir.join("Cargo.toml");
if !manifest.is_file() {
return Err(eyre!(
"libkrun Cargo.toml not found at '{}'",
manifest.display()
));
}
return Ok(GuestInitBuildSource::Checkout {
libkrun_dir,
manifest,
});
}
let build_package_dir = target_dir_parent.join("krun-init-build-package");
let manifest = copy_krun_init_blob_build_package(&build_package_dir)?;
Ok(GuestInitBuildSource::CratesIo { manifest })
}
fn copy_krun_init_blob_build_package(package_dir: &Path) -> Result<PathBuf> {
let source_dir = krun_init_blob_build_package_source_dir();
let src_dir = package_dir.join("src");
fs::create_dir_all(&src_dir).wrap_err_with(|| {
format!(
"create guest init build package source dir '{}'",
src_dir.display()
)
})?;
let manifest = package_dir.join("Cargo.toml");
copy_regular_file_atomic(&source_dir.join("Cargo.toml"), &manifest)?;
copy_regular_file_atomic(&source_dir.join("src/lib.rs"), &src_dir.join("lib.rs"))?;
Ok(manifest)
}
fn krun_init_blob_build_package_source_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("krun-init-build-package")
}
fn copy_regular_file_atomic(source: &Path, destination: &Path) -> Result<()> {
let destination_dir = path_parent_or_current(destination);
fs::create_dir_all(&destination_dir).wrap_err_with(|| {
format!(
"create directory for copied file '{}'",
destination.display()
)
})?;
let mut source_file = fs::File::open(source)
.wrap_err_with(|| format!("open source file '{}'", source.display()))?;
let mut temp_file = tempfile::Builder::new()
.prefix(".copy-")
.tempfile_in(&destination_dir)
.wrap_err_with(|| format!("create temporary file for '{}'", destination.display()))?;
std::io::copy(&mut source_file, temp_file.as_file_mut()).wrap_err_with(|| {
format!(
"copy '{}' to temporary file for '{}'",
source.display(),
destination.display()
)
})?;
temp_file
.as_file_mut()
.sync_all()
.wrap_err_with(|| format!("sync temporary file for '{}'", destination.display()))?;
fs::rename(temp_file.path(), destination)
.wrap_err_with(|| format!("move copied file '{}' into place", destination.display()))?;
Ok(())
}
fn reset_guest_init_build_output(build: &ResolvedGuestInitBuild) -> Result<()> {
let output_search_dir = build.cargo_target_dir.join(&build.guest_target);
if !output_search_dir.exists() {
return Ok(());
}
fs::remove_dir_all(&output_search_dir).wrap_err_with(|| {
format!(
"remove stale guest init build output '{}' before rebuilding",
output_search_dir.display()
)
})
}
fn run_guest_init_build(build: &ResolvedGuestInitBuild) -> Result<PathBuf> {
let mut cargo =
Command::new(std::env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo")));
if let Some(toolchain) = &build.cargo_toolchain {
let mut arg = OsString::from("+");
arg.push(toolchain);
cargo.arg(arg);
}
let cargo_subcommand = "build";
cargo.arg(cargo_subcommand);
if matches!(&build.source, GuestInitBuildSource::Checkout { .. }) {
cargo.arg("-p").arg("krun-init-blob");
}
let manifest = build.source.manifest();
let output_search_dir = build.cargo_target_dir.join(&build.guest_target);
let status = cargo
.arg("--manifest-path")
.arg(manifest)
.arg("--target")
.arg(&build.guest_target)
.arg("--target-dir")
.arg(&build.cargo_target_dir)
.env("CC_LINUX", &build.cc_linux)
.status()
.wrap_err_with(|| {
format!(
"run cargo {cargo_subcommand} for krun-init-blob from {} for guest target '{}'",
build.source.description(),
build.guest_target
)
})?;
if !status.success() {
return Err(eyre!(
"cargo {cargo_subcommand} failed while building krun-init-blob from {} for guest target '{}' with {status}",
build.source.description(),
build.guest_target
));
}
find_krun_init_blob_output(&output_search_dir).wrap_err_with(|| {
format!(
"find krun-init-blob output under '{}'",
output_search_dir.display()
)
})
}
impl GuestInitBuildSource {
fn manifest(&self) -> &Path {
match self {
Self::Checkout { manifest, .. } | Self::CratesIo { manifest } => manifest,
}
}
fn description(&self) -> String {
match self {
Self::Checkout { libkrun_dir, .. } => {
format!("libkrun checkout '{}'", libkrun_dir.display())
}
Self::CratesIo { .. } => "crates.io krun-init-blob build package".to_owned(),
}
}
}
fn validate_guest_init_target(
guest_target: &str,
cargo_toolchain: Option<&OsString>,
) -> Result<()> {
match guest_init_target_os(guest_target) {
Some("linux") => {}
_ => {
return Err(eyre!(
"unsupported guest init target '{guest_target}'; target OS must be linux"
));
}
}
validate_guest_target_arch_matches_host(guest_target)?;
if rustc_target_list_contains(guest_target, cargo_toolchain)? {
return Ok(());
}
Err(eyre!(
"unsupported guest init target '{guest_target}'; target must be a rustup target listed by `rustc --print target-list`"
))
}
fn rustc_target_list_contains(
guest_target: &str,
cargo_toolchain: Option<&OsString>,
) -> Result<bool> {
let mut rustc =
Command::new(std::env::var_os("RUSTC").unwrap_or_else(|| OsString::from("rustc")));
if let Some(toolchain) = cargo_toolchain {
let mut arg = OsString::from("+");
arg.push(toolchain);
rustc.arg(arg);
}
let output = rustc
.arg("--print")
.arg("target-list")
.output()
.wrap_err("list rust targets with rustc")?;
if !output.status.success() {
return Err(eyre!(
"rustc failed while listing supported targets with {}",
output.status
));
}
let stdout = String::from_utf8(output.stdout).wrap_err("parse rustc target list as UTF-8")?;
Ok(stdout.lines().any(|target| target == guest_target))
}
fn validate_guest_target_arch_matches_host(guest_target: &str) -> Result<()> {
let Some(guest_arch) = guest_target_arch(guest_target) else {
return Err(eyre!(
"unsupported guest init target '{guest_target}'; target must start with a Rust target architecture"
));
};
let host_arch = std::env::consts::ARCH;
if guest_arch == host_arch {
return Ok(());
}
Err(eyre!(
"guest target '{guest_target}' has arch '{guest_arch}', but vmrunner requires host arch '{host_arch}'"
))
}
fn guest_target_arch(guest_target: &str) -> Option<&str> {
let arch = guest_target.split('-').next()?;
(!arch.is_empty()).then_some(arch)
}
fn guest_init_target_os(guest_target: &str) -> Option<&str> {
guest_target.split('-').find(|part| *part == "linux")
}
fn install_krun_init_blob_output(
built_init: &Path,
init_path: &Path,
fingerprint: Option<&GuestInitFingerprint>,
) -> Result<()> {
replace_guest_init_output(built_init, init_path)?;
if let Some(fingerprint) = fingerprint {
write_guest_init_fingerprint(&guest_init_fingerprint_path(init_path), fingerprint)?;
}
Ok(())
}
fn replace_guest_init_output(built_init: &Path, init_path: &Path) -> Result<()> {
let init_dir = path_parent_or_current(init_path);
let mut tmp_init = tempfile::Builder::new()
.prefix(".init.krun-")
.tempfile_in(&init_dir)
.wrap_err_with(|| format!("create temporary guest init under '{}'", init_dir.display()))?;
let mut source = fs::File::open(built_init)
.wrap_err_with(|| format!("open built guest init '{}'", built_init.display()))?;
std::io::copy(&mut source, tmp_init.as_file_mut()).wrap_err_with(|| {
format!(
"copy built guest init '{}' to temporary path '{}'",
built_init.display(),
tmp_init.path().display()
)
})?;
tmp_init
.as_file_mut()
.sync_all()
.wrap_err_with(|| format!("sync temporary guest init '{}'", tmp_init.path().display()))?;
fs::set_permissions(tmp_init.path(), permissions_0755()).wrap_err_with(|| {
format!(
"set executable permissions on temporary guest init '{}'",
tmp_init.path().display()
)
})?;
fs::rename(tmp_init.path(), init_path).wrap_err_with(|| {
format!(
"install guest init '{}' into '{}'",
built_init.display(),
init_path.display()
)
})
}
fn guest_init_target_dir(
target_dir_parent: &Path,
guest_target: &str,
explicit_target_dir: Option<PathBuf>,
) -> Result<PathBuf> {
if let Some(path) =
explicit_target_dir.or_else(|| std::env::var_os("KRUN_INIT_TARGET_DIR").map(PathBuf::from))
{
fs::create_dir_all(&path)
.wrap_err_with(|| format!("create guest init target dir '{}'", path.display()))?;
return Ok(path);
}
let target_dir = target_dir_parent
.join("krun-init-target")
.join(safe_guest_target_name(guest_target));
fs::create_dir_all(&target_dir)
.wrap_err_with(|| format!("create guest init target dir '{}'", target_dir.display()))?;
Ok(target_dir)
}
fn safe_guest_target_name(guest_target: &str) -> String {
String::from_iter(guest_target.chars().map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
ch
} else {
'-'
}
}))
}
fn path_parent_or_current(path: &Path) -> PathBuf {
path.parent()
.filter(|path| !path.as_os_str().is_empty())
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."))
}
fn find_krun_init_blob_output(target_dir: &Path) -> Result<PathBuf> {
let mut stack = vec![target_dir.to_path_buf()];
let mut newest: Option<(std::time::SystemTime, PathBuf)> = None;
while let Some(path) = stack.pop() {
for entry in
fs::read_dir(&path).wrap_err_with(|| format!("read directory '{}'", path.display()))?
{
let entry = entry?;
let entry_path = entry.path();
let file_type = entry.file_type()?;
if file_type.is_dir() {
stack.push(entry_path);
continue;
}
if !file_type.is_file() || entry.file_name() != "init" {
continue;
}
if !is_krun_init_blob_output(&entry_path) {
continue;
}
let modified = entry
.metadata()
.and_then(|metadata| metadata.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
if newest
.as_ref()
.is_none_or(|(newest_modified, _)| modified > *newest_modified)
{
newest = Some((modified, entry_path));
}
}
}
newest
.map(|(_, path)| path)
.ok_or_else(|| eyre!("krun-init-blob output init not found"))
}
fn is_krun_init_blob_output(path: &Path) -> bool {
let Some(out_dir) = path.parent() else {
return false;
};
if out_dir.file_name().and_then(|name| name.to_str()) != Some("out") {
return false;
}
let Some(package_build_dir) = out_dir.parent() else {
return false;
};
package_build_dir
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("krun-init-blob-"))
}
#[cfg(unix)]
fn permissions_0755() -> std::fs::Permissions {
use std::os::unix::fs::PermissionsExt;
std::fs::Permissions::from_mode(0o755)
}
#[cfg(not(unix))]
fn permissions_0755() -> std::fs::Permissions {
std::fs::metadata(".")
.map(|metadata| metadata.permissions())
.unwrap_or_else(|_| std::fs::Permissions::readonly())
}
pub fn run_current_test_in_platform_child(test_name: &str) -> Result<bool> {
run_current_test_in_platform_child_impl(test_name)
}
#[cfg(target_os = "linux")]
fn run_current_test_in_platform_child_impl(test_name: &str) -> Result<bool> {
run_current_test_in_unshare_child(test_name)
}
#[cfg(target_os = "macos")]
fn run_current_test_in_platform_child_impl(test_name: &str) -> Result<bool> {
run_current_test_in_codesigned_child(test_name)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn run_current_test_in_platform_child_impl(_test_name: &str) -> Result<bool> {
Ok(false)
}
#[cfg(target_os = "linux")]
pub fn run_current_test_in_unshare_child(test_name: &str) -> Result<bool> {
run_current_test_with_unshare_args(
test_name,
"VMRUNNER_UNSHARE_CHILD_TEST",
&["--user", "--map-root-user", "--net", "--fork", "--"],
"user+network namespace",
)
}
#[cfg(target_os = "linux")]
pub fn run_current_test_in_userns_child(test_name: &str) -> Result<bool> {
run_current_test_with_unshare_args(
test_name,
"VMRUNNER_USERNS_CHILD_TEST",
&["--user", "--map-root-user", "--fork", "--"],
"user namespace",
)
}
#[cfg(target_os = "linux")]
fn run_current_test_with_unshare_args(
test_name: &str,
child_env: &str,
unshare_args: &[&str],
description: &str,
) -> Result<bool> {
if std::env::var(child_env).as_deref() == Ok(test_name) {
return Ok(false);
}
let current_test_binary = std::env::current_exe().wrap_err("resolve current test binary")?;
let mut command = Command::new("unshare");
command
.args(unshare_args)
.arg(current_test_binary)
.arg("--exact")
.arg(test_name)
.arg("--nocapture")
.env(child_env, test_name);
run_current_test_child_command(command, test_name, description)
}
#[cfg(target_os = "macos")]
pub fn run_current_test_in_codesigned_child(test_name: &str) -> Result<bool> {
const CHILD_ENV: &str = "VMRUNNER_CODESIGN_CHILD_TEST";
if std::env::var(CHILD_ENV).as_deref() == Ok(test_name) {
return Ok(false);
}
let current_test_binary = std::env::current_exe().wrap_err("resolve current test binary")?;
ensure_macos_test_binary_codesigned(¤t_test_binary)?;
let mut command = Command::new(current_test_binary);
command
.arg("--exact")
.arg(test_name)
.arg("--nocapture")
.env(CHILD_ENV, test_name);
run_current_test_child_command(command, test_name, "codesigned macOS child process")
}
#[cfg(target_os = "macos")]
const MACOS_HYPERVISOR_ENTITLEMENTS: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.hypervisor</key>
<true/>
</dict>
</plist>
"#;
#[cfg(target_os = "macos")]
fn ensure_macos_test_binary_codesigned(test_binary: &Path) -> Result<()> {
if macos_binary_has_hypervisor_entitlement(test_binary)? {
return Ok(());
}
let _lock = MacosCodesignLock::acquire(test_binary)?;
if macos_binary_has_hypervisor_entitlement(test_binary)? {
return Ok(());
}
codesign_macos_test_binary(test_binary)
}
#[cfg(target_os = "macos")]
fn macos_binary_has_hypervisor_entitlement(test_binary: &Path) -> Result<bool> {
let output = Command::new("codesign")
.args(["--display", "--entitlements", ":-", "--xml"])
.arg(test_binary)
.output()
.wrap_err_with(|| {
format!(
"inspect macOS code signature entitlements for '{}'",
test_binary.display()
)
})?;
if !output.status.success() {
return Ok(false);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
Ok(stdout.contains("<key>com.apple.security.hypervisor</key>")
|| stderr.contains("<key>com.apple.security.hypervisor</key>"))
}
#[cfg(target_os = "macos")]
fn codesign_macos_test_binary(test_binary: &Path) -> Result<()> {
let mut entitlements = tempfile::Builder::new()
.prefix("vmrunner-hypervisor-")
.suffix(".plist")
.tempfile()
.wrap_err("create temporary macOS codesign entitlements file")?;
entitlements
.write_all(MACOS_HYPERVISOR_ENTITLEMENTS.as_bytes())
.wrap_err("write macOS Hypervisor.framework entitlements")?;
entitlements
.as_file_mut()
.sync_all()
.wrap_err("sync macOS codesign entitlements file")?;
let output = Command::new("codesign")
.args(["--force", "--sign", "-", "--entitlements"])
.arg(entitlements.path())
.arg(test_binary)
.output()
.wrap_err_with(|| format!("codesign macOS test binary '{}'", test_binary.display()))?;
if output.status.success() {
return Ok(());
}
Err(eyre!(
"codesign failed for macOS test binary '{}' with {}\nstdout:\n{}\nstderr:\n{}",
test_binary.display(),
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
))
}
#[cfg(target_os = "macos")]
struct MacosCodesignLock {
_file: std::fs::File,
}
#[cfg(target_os = "macos")]
impl MacosCodesignLock {
fn acquire(test_binary: &Path) -> Result<Self> {
let lock_path = macos_codesign_lock_path(test_binary);
let lock_dir = path_parent_or_current(&lock_path);
fs::create_dir_all(&lock_dir)
.wrap_err_with(|| format!("create macOS codesign lock dir '{}'", lock_dir.display()))?;
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)
.wrap_err_with(|| format!("open macOS codesign lock '{}'", lock_path.display()))?;
file.lock_exclusive().wrap_err_with(|| {
format!(
"lock macOS codesign artifact '{}'; another vmrunner process may be signing it",
lock_path.display()
)
})?;
Ok(Self { _file: file })
}
}
#[cfg(target_os = "macos")]
fn macos_codesign_lock_path(test_binary: &Path) -> PathBuf {
let mut lock_name = OsString::from(".");
if let Some(file_name) = test_binary.file_name() {
lock_name.push(file_name);
} else {
lock_name.push("test-binary");
}
lock_name.push(".codesign.lock");
path_parent_or_current(test_binary).join(lock_name)
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn run_current_test_child_command(
mut command: Command,
test_name: &str,
description: &str,
) -> Result<bool> {
let output = command
.output()
.wrap_err_with(|| format!("launch child test process in {description}"))?;
write_child_test_output(&output.stdout, test_name, std::io::stdout().lock())?;
write_child_test_output(&output.stderr, test_name, std::io::stderr().lock())?;
if !output.status.success() {
return Err(eyre!(
"child test process in {description} failed with {}",
output.status
));
}
Ok(true)
}
fn write_child_test_output(
output: &[u8],
test_name: &str,
mut writer: impl std::io::Write,
) -> Result<()> {
let output = String::from_utf8_lossy(output);
for line in output.lines() {
if is_child_test_harness_line(line, test_name) {
continue;
}
writeln!(writer, "{line}").wrap_err("write child test output")?;
}
Ok(())
}
fn is_child_test_harness_line(line: &str, test_name: &str) -> bool {
let line = line.trim_end();
if line.is_empty()
|| (line.starts_with("running ") && line.ends_with(" test"))
|| (line.starts_with("running ") && line.ends_with(" tests"))
|| line.starts_with("test result: ")
|| line == "failures:"
|| line == format!(" {test_name}")
{
return true;
}
line.starts_with("test ")
&& line.contains(test_name)
&& (line.ends_with(" ... ok")
|| line.ends_with(" ... FAILED")
|| line.ends_with(" ... ignored"))
}
#[cfg(not(target_os = "linux"))]
pub fn run_current_test_in_unshare_child(_test_name: &str) -> Result<bool> {
Err(eyre!("user+network namespaces are only supported on Linux"))
}
#[cfg(not(target_os = "linux"))]
pub fn run_current_test_in_userns_child(_test_name: &str) -> Result<bool> {
Err(eyre!("user namespaces are only supported on Linux"))
}
#[cfg(not(target_os = "macos"))]
pub fn run_current_test_in_codesigned_child(_test_name: &str) -> Result<bool> {
Err(eyre!(
"macOS codesign child tests are only supported on macOS"
))
}
#[cfg(test)]
mod tests {
use super::{
EXTERNAL_QCOW_FILESYSTEMS, FormatAccess, FormatDriverBuilder, GuestInitArtifactLock,
GuestInitBuildSource, GuestInitFingerprint, ImagoFile, MkosiQcow2Prep,
PermissiveImplicitOpenGate, QCOW2_IMAGE_CACHE_DIR, Qcow2, ResolvedGuestInitBuild,
convert_raw_image_to_qcow2, copy_krun_init_blob_build_package, default_guest_init_cc,
download_qcow2_image_to_temp, ensure_guest_init, ensure_mkosi_base_tree_usrmerge_symlinks,
ensure_root_qcow2, find_krun_init_blob_output, guest_init_artifact_is_current,
guest_init_artifact_lock_path, guest_init_cargo_target, guest_init_fingerprint_path,
guest_init_path_for_root_qcow2, is_krun_init_blob_output, known_mkosi_qcow2_prep,
known_qcow2_image_sha256, krun_init_blob_build_package_source_dir,
mkosi_prepared_qcow2_path, mkosi_qcow2_prep_sha256, normalize_linux_guest_init_cc,
normalize_sha256, qcow2_image_cache_path, qcow2_image_cache_path_with_sha256,
reset_guest_init_build_output, sha256_directory, sha256_file, validate_guest_init_target,
with_guest_init_artifact_lock, write_guest_init_fingerprint,
};
use color_eyre::eyre::{Result, eyre};
use fs_err as fs;
use fs2::FileExt;
use std::{
cell::Cell,
io::{Read as _, Write as _},
net::TcpListener,
path::Path,
thread,
time::{Duration, Instant},
};
fn serve_qcow2_once(body: &[u8]) -> Result<(String, thread::JoinHandle<Result<()>>)> {
let listener = TcpListener::bind(("127.0.0.1", 0))?;
listener.set_nonblocking(true)?;
let url = format!("http://{}/image.qcow2", listener.local_addr()?);
let body = body.to_vec();
let server = thread::spawn(move || {
let start = Instant::now();
let (mut stream, _) = loop {
match listener.accept() {
Ok(connection) => break connection,
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
if start.elapsed() >= Duration::from_secs(5) {
return Err(eyre!("timed out waiting for test HTTP request"));
}
thread::sleep(Duration::from_millis(10));
}
Err(error) => return Err(error.into()),
}
};
let mut request = [0; 4096];
let _ = stream.read(&mut request)?;
let headers = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/octet-stream\r\nConnection: close\r\n\r\n",
body.len()
);
stream.write_all(headers.as_bytes())?;
stream.write_all(&body)?;
stream.flush()?;
Ok(())
});
Ok((url, server))
}
#[test]
fn qcow2_image_cache_path_uses_cache_dir_and_url_file_name() {
assert_eq!(
qcow2_image_cache_path("https://example.test/images/my cloud image.qcow2?download=1"),
Path::new(QCOW2_IMAGE_CACHE_DIR).join("my-cloud-image.qcow2")
);
}
#[test]
fn qcow2_image_cache_path_falls_back_for_directory_url() {
assert_eq!(
qcow2_image_cache_path("https://example.test/images/"),
Path::new(QCOW2_IMAGE_CACHE_DIR).join("image.qcow2")
);
}
#[test]
fn qcow2_image_cache_path_with_sha256_uses_digest_dir() -> Result<()> {
let digest = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
assert_eq!(
qcow2_image_cache_path_with_sha256("https://example.test/images/root.qcow2", digest)?,
Path::new(QCOW2_IMAGE_CACHE_DIR)
.join("sha256")
.join(digest.to_ascii_lowercase())
.join("root.qcow2")
);
Ok(())
}
#[test]
fn normalize_sha256_rejects_invalid_digest() {
assert!(normalize_sha256("not-a-digest").is_err());
assert!(normalize_sha256("abc").is_err());
}
#[test]
fn known_image_sha256_matches_exported_filesystem_metadata() {
let image = EXTERNAL_QCOW_FILESYSTEMS
.iter()
.find(|image| image.name == "ubuntu" && image.arch == "x86_64")
.expect("ubuntu x86_64 image metadata should be exported");
assert_eq!(known_qcow2_image_sha256(image.url), Some(image.digest));
assert_eq!(
known_qcow2_image_sha256("https://example.test/root.qcow2"),
None
);
}
#[test]
fn ensure_root_qcow2_keeps_local_paths() -> Result<()> {
assert_eq!(
ensure_root_qcow2("images/root.qcow2")?,
Path::new("images/root.qcow2")
);
Ok(())
}
#[test]
fn known_images_have_mkosi_sysroot_preparation() -> Result<()> {
for image in EXTERNAL_QCOW_FILESYSTEMS {
let prep = known_mkosi_qcow2_prep(image.url).ok_or_else(|| {
eyre!(
"external image {} ({}) has no mkosi preparation metadata",
image.name,
image.arch
)
})?;
assert!(!prep.packages.is_empty());
assert_eq!(prep.use_default_tools_tree, image.name == "ubuntu");
}
assert!(known_mkosi_qcow2_prep("https://example.test/root.qcow2").is_none());
Ok(())
}
#[test]
fn mkosi_prepared_qcow2_path_is_derived_from_source_and_prep_digests() -> Result<()> {
let prep = known_mkosi_qcow2_prep(EXTERNAL_QCOW_FILESYSTEMS[1].url).unwrap();
let prep_sha256 = mkosi_qcow2_prep_sha256(prep);
let source_sha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let path = mkosi_prepared_qcow2_path(
Path::new("target/vmrunner-qcow2-imag/sha256/source/Fedora.qcow2"),
prep,
source_sha256,
&prep_sha256,
)?;
assert_eq!(
path,
Path::new("target/vmrunner-qcow2-imag/sha256/source")
.join("mkosi")
.join("fedora")
.join("x86-64")
.join("sha256")
.join(source_sha256)
.join("prep-sha256")
.join(prep_sha256)
.join("Fedora.mkosi.qcow2")
);
Ok(())
}
#[test]
fn mkosi_prep_fingerprint_changes_when_required_image_update_changes() {
let base = *known_mkosi_qcow2_prep(EXTERNAL_QCOW_FILESYSTEMS[1].url).unwrap();
let changed_package = MkosiQcow2Prep {
packages: &["glibc-devel", "kernel-headers"],
..base
};
let changed_release = MkosiQcow2Prep {
release: "45",
..base
};
assert_ne!(
mkosi_qcow2_prep_sha256(&base),
mkosi_qcow2_prep_sha256(&changed_package)
);
assert_ne!(
mkosi_qcow2_prep_sha256(&base),
mkosi_qcow2_prep_sha256(&changed_release)
);
}
#[test]
fn mkosi_base_tree_has_usrmerge_symlinks_for_package_scripts() -> Result<()> {
let dir = tempfile::tempdir()?;
fs::create_dir_all(dir.path().join("usr/bin"))?;
fs::create_dir_all(dir.path().join("usr/sbin"))?;
fs::create_dir_all(dir.path().join("usr/lib"))?;
fs::create_dir_all(dir.path().join("usr/lib64"))?;
ensure_mkosi_base_tree_usrmerge_symlinks(dir.path())?;
assert_eq!(fs::read_link(dir.path().join("bin"))?, Path::new("usr/bin"));
assert_eq!(
fs::read_link(dir.path().join("sbin"))?,
Path::new("usr/sbin")
);
assert_eq!(fs::read_link(dir.path().join("lib"))?, Path::new("usr/lib"));
assert_eq!(
fs::read_link(dir.path().join("lib64"))?,
Path::new("usr/lib64")
);
Ok(())
}
#[test]
fn raw_to_qcow2_conversion_does_not_shell_out() -> Result<()> {
let dir = tempfile::tempdir()?;
let raw = dir.path().join("root.raw");
let qcow2 = dir.path().join("root.qcow2");
let mut raw_bytes = vec![0; 8192];
raw_bytes[512..517].copy_from_slice(b"hello");
fs::write(&raw, &raw_bytes)?;
convert_raw_image_to_qcow2(&raw, &qcow2)?;
let converted = futures::executor::block_on(async {
let qcow2 = Qcow2::<ImagoFile>::builder_path(&qcow2)
.open(PermissiveImplicitOpenGate::default())
.await?;
let qcow2 = FormatAccess::new(qcow2);
let mut converted = vec![0; raw_bytes.len()];
qcow2.read(&mut converted, 0).await?;
Ok::<_, std::io::Error>(converted)
})?;
assert_eq!(converted, raw_bytes);
Ok(())
}
#[test]
fn download_qcow2_image_to_temp_fetches_http_response() -> Result<()> {
let body = b"qcow2 image bytes";
let (url, server) = serve_qcow2_once(body)?;
let cache_dir = tempfile::tempdir()?;
let cache_path = cache_dir.path().join("image.qcow2");
let download = download_qcow2_image_to_temp(&url, &cache_path);
server
.join()
.map_err(|_| eyre!("test HTTP server thread panicked"))??;
let temp_file = download?;
assert_eq!(fs::read(temp_file.path())?, body);
Ok(())
}
#[test]
fn guest_init_target_validation_accepts_host_arch_linux() {
let host_arch = std::env::consts::ARCH;
if !matches!(host_arch, "aarch64" | "x86_64") {
return;
}
validate_guest_init_target(&format!("{host_arch}-unknown-linux-gnu"), None).unwrap();
validate_guest_init_target(&format!("{host_arch}-unknown-linux-musl"), None).unwrap();
}
#[test]
fn default_guest_init_cc_uses_clang_without_zig() {
let host_arch = std::env::consts::ARCH;
let linux_target = format!("{host_arch}-unknown-linux-gnu");
assert_eq!(
default_guest_init_cc(&linux_target),
format!("clang -target {linux_target}")
);
}
#[test]
fn linux_guest_init_cargo_target_preserves_guest_target() {
assert_eq!(
guest_init_cargo_target("aarch64-unknown-linux-gnu"),
"aarch64-unknown-linux-gnu"
);
assert_eq!(
guest_init_cargo_target("aarch64-unknown-linux-musl"),
"aarch64-unknown-linux-musl"
);
}
#[test]
fn linux_guest_init_cc_uses_extracted_sysroot_without_zig() -> Result<()> {
let sysroot = Path::new("/guest/sysroot");
assert_eq!(
normalize_linux_guest_init_cc("clang", "x86_64-unknown-linux-gnu", sysroot)?,
"clang -target x86_64-unknown-linux-gnu --sysroot /guest/sysroot"
);
assert_eq!(
normalize_linux_guest_init_cc(
"clang --target=aarch64-unknown-linux-gnu",
"x86_64-unknown-linux-gnu",
sysroot,
)?,
"clang -target x86_64-unknown-linux-gnu --sysroot /guest/sysroot"
);
assert_eq!(
normalize_linux_guest_init_cc("cc", "x86_64-unknown-linux-gnu", sysroot)?,
"cc --sysroot /guest/sysroot"
);
assert!(
normalize_linux_guest_init_cc("zig cc", "x86_64-unknown-linux-gnu", sysroot).is_err()
);
Ok(())
}
#[test]
fn guest_init_target_validation_rejects_other_targets() {
assert!(validate_guest_init_target("aarch64-linux-musl", None).is_err());
assert!(validate_guest_init_target("aarch64-apple-darwin", None).is_err());
assert!(validate_guest_init_target("x86_64-pc-windows-msvc", None).is_err());
assert!(validate_guest_init_target("notlinux", None).is_err());
}
#[test]
fn guest_init_target_validation_rejects_non_host_arch() {
let wrong_arch_target = match std::env::consts::ARCH {
"aarch64" => Some("x86_64-unknown-linux-gnu"),
"x86_64" => Some("aarch64-unknown-linux-gnu"),
_ => None,
};
if let Some(wrong_arch_target) = wrong_arch_target {
assert!(validate_guest_init_target(wrong_arch_target, None).is_err());
}
}
#[test]
fn krun_init_blob_output_path_must_match_cargo_build_script_layout() {
assert!(is_krun_init_blob_output(Path::new(
"target/debug/build/krun-init-blob-abc/out/init"
)));
assert!(!is_krun_init_blob_output(Path::new(
"target/debug/build/other-package-abc/out/init"
)));
assert!(!is_krun_init_blob_output(Path::new(
"target/debug/build/krun-init-blob-abc/not-out/init"
)));
}
#[test]
fn find_krun_init_blob_output_finds_nested_init() -> Result<()> {
let target_dir = tempfile::tempdir()?;
let output_dir = target_dir
.path()
.join("debug")
.join("build")
.join("krun-init-blob-abc")
.join("out");
fs::create_dir_all(&output_dir)?;
let init_path = output_dir.join("init");
fs::write(&init_path, b"init")?;
assert_eq!(find_krun_init_blob_output(target_dir.path())?, init_path);
Ok(())
}
#[test]
fn reset_guest_init_build_output_removes_only_guest_target_dir() -> Result<()> {
let target_dir = tempfile::tempdir()?;
let guest_target = "x86_64-unknown-linux-gnu";
let stale_dir = target_dir.path().join(guest_target);
let other_dir = target_dir.path().join("host");
fs::create_dir_all(&stale_dir)?;
fs::create_dir_all(&other_dir)?;
fs::write(stale_dir.join("old"), b"old")?;
fs::write(other_dir.join("keep"), b"keep")?;
reset_guest_init_build_output(&ResolvedGuestInitBuild {
guest_target: guest_target.to_owned(),
source: GuestInitBuildSource::CratesIo {
manifest: target_dir.path().join("Cargo.toml"),
},
cargo_target_dir: target_dir.path().to_path_buf(),
cargo_toolchain: None,
cc_linux: String::new(),
qcow_sha256: None,
sysroot_sha256: String::new(),
})?;
assert!(!stale_dir.exists());
assert!(other_dir.join("keep").is_file());
Ok(())
}
#[test]
fn ensure_guest_init_requires_target_to_check_existing_sidecar() -> Result<()> {
if std::env::var_os("KRUN_INIT_GUEST_TARGET").is_some() {
return Ok(());
}
let image_dir = tempfile::tempdir()?;
let root_qcow2 = image_dir.path().join("root.qcow2");
fs::write(&root_qcow2, b"qcow2")?;
fs::write(guest_init_path_for_root_qcow2(&root_qcow2), b"init")?;
let error = ensure_guest_init(&root_qcow2, None, None)
.expect_err("existing sidecars still need a target for fingerprint validation");
assert!(error.to_string().contains("missing guest init target"));
Ok(())
}
#[test]
fn ensure_guest_init_validates_target_when_sidecar_exists() -> Result<()> {
let wrong_arch_target = match std::env::consts::ARCH {
"aarch64" => Some("x86_64-unknown-linux-gnu"),
"x86_64" => Some("aarch64-unknown-linux-gnu"),
_ => None,
};
let Some(wrong_arch_target) = wrong_arch_target else {
return Ok(());
};
let image_dir = tempfile::tempdir()?;
let root_qcow2 = image_dir.path().join("root.qcow2");
fs::write(&root_qcow2, b"qcow2")?;
fs::write(guest_init_path_for_root_qcow2(&root_qcow2), b"init")?;
assert!(ensure_guest_init(&root_qcow2, Some(wrong_arch_target), None).is_err());
Ok(())
}
#[test]
fn guest_init_artifact_lock_path_is_hidden_sibling() {
assert_eq!(
guest_init_path_for_root_qcow2(Path::new("images/root.qcow2")),
Path::new("images/.root.qcow2.init.krun")
);
assert_eq!(
guest_init_artifact_lock_path(Path::new("images/.root.qcow2.init.krun")),
Path::new("images/..root.qcow2.init.krun.lock")
);
assert_eq!(
guest_init_fingerprint_path(Path::new("images/.root.qcow2.init.krun")),
Path::new("images/.root.qcow2.init.krun.fingerprint")
);
}
#[test]
fn guest_init_fingerprint_changes_with_components() {
let binary = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let qcow = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
let sysroot = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
let baseline =
GuestInitFingerprint::new(binary.to_owned(), qcow.to_owned(), sysroot.to_owned());
assert_ne!(
baseline.fingerprint,
GuestInitFingerprint::new(
"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd".to_owned(),
qcow.to_owned(),
sysroot.to_owned(),
)
.fingerprint
);
assert_ne!(
baseline.fingerprint,
GuestInitFingerprint::new(
binary.to_owned(),
"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee".to_owned(),
sysroot.to_owned(),
)
.fingerprint
);
assert_ne!(
baseline.fingerprint,
GuestInitFingerprint::new(
binary.to_owned(),
qcow.to_owned(),
"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff".to_owned(),
)
.fingerprint
);
}
#[test]
fn guest_init_artifact_currentness_uses_binary_qcow_and_sysroot_fingerprint() -> Result<()> {
let dir = tempfile::tempdir()?;
let init = dir.path().join(".root.qcow2.init.krun");
let qcow = dir.path().join("root.qcow2");
let sysroot = dir.path().join("sysroot");
fs::write(&init, b"init")?;
fs::write(&qcow, b"qcow")?;
fs::create_dir_all(sysroot.join("usr/include"))?;
fs::write(sysroot.join("usr/include/header.h"), b"header")?;
let qcow_sha256 = sha256_file(&qcow)?;
let sysroot_sha256 = sha256_directory(&sysroot)?;
assert!(!guest_init_artifact_is_current(
&init,
Some(&qcow_sha256),
&sysroot_sha256
)?);
let fingerprint = GuestInitFingerprint::from_paths(&init, &qcow, &sysroot)?;
write_guest_init_fingerprint(&guest_init_fingerprint_path(&init), &fingerprint)?;
assert!(guest_init_artifact_is_current(
&init,
Some(&qcow_sha256),
&sysroot_sha256
)?);
fs::write(&init, b"changed init")?;
assert!(!guest_init_artifact_is_current(
&init,
Some(&qcow_sha256),
&sysroot_sha256
)?);
fs::write(&init, b"init")?;
assert!(guest_init_artifact_is_current(
&init,
Some(&qcow_sha256),
&sysroot_sha256
)?);
fs::write(&qcow, b"changed qcow")?;
let changed_qcow_sha256 = sha256_file(&qcow)?;
assert!(!guest_init_artifact_is_current(
&init,
Some(&changed_qcow_sha256),
&sysroot_sha256
)?);
fs::write(&qcow, b"qcow")?;
assert!(guest_init_artifact_is_current(
&init,
Some(&qcow_sha256),
&sysroot_sha256
)?);
fs::write(sysroot.join("usr/include/header.h"), b"changed header")?;
let changed_sysroot_sha256 = sha256_directory(&sysroot)?;
assert!(!guest_init_artifact_is_current(
&init,
Some(&qcow_sha256),
&changed_sysroot_sha256
)?);
Ok(())
}
#[test]
fn guest_init_artifact_lock_is_exclusive() -> Result<()> {
let image_dir = tempfile::tempdir()?;
let init_path = image_dir.path().join(".root.qcow2.init.krun");
let first_lock = GuestInitArtifactLock::acquire(&init_path)?;
let lock_path = guest_init_artifact_lock_path(&init_path);
let second_file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(&lock_path)?;
let error = second_file
.try_lock_exclusive()
.expect_err("second lock acquisition should fail while first lock is held");
assert_eq!(error.kind(), std::io::ErrorKind::WouldBlock);
drop(first_lock);
second_file.lock_exclusive()?;
second_file.unlock()?;
Ok(())
}
#[test]
fn guest_init_artifact_lock_rechecks_output_before_build() -> Result<()> {
let image_dir = tempfile::tempdir()?;
let init_path = image_dir.path().join(".root.qcow2.init.krun");
fs::write(&init_path, b"init")?;
let called = Cell::new(false);
let built = with_guest_init_artifact_lock(&init_path, || {
if init_path.exists() {
return Ok(false);
}
called.set(true);
Ok(true)
})?;
assert!(!built);
assert!(!called.get());
assert!(guest_init_artifact_lock_path(&init_path).is_file());
Ok(())
}
#[test]
fn guest_init_crates_io_build_package_is_copied() -> Result<()> {
let package_dir = tempfile::tempdir()?;
let manifest = copy_krun_init_blob_build_package(package_dir.path())?;
let source_dir = krun_init_blob_build_package_source_dir();
assert_eq!(
fs::read_to_string(&manifest)?,
fs::read_to_string(source_dir.join("Cargo.toml"))?
);
assert_eq!(
fs::read_to_string(package_dir.path().join("src/lib.rs"))?,
fs::read_to_string(source_dir.join("src/lib.rs"))?
);
Ok(())
}
#[test]
fn linux_unshare_child_harness_filter_keeps_vm_output_and_errors() -> Result<()> {
assert_child_harness_filter_keeps_vm_output_and_errors()
}
#[test]
fn macos_codesign_child_harness_filter_keeps_vm_output_and_errors() -> Result<()> {
assert_child_harness_filter_keeps_vm_output_and_errors()
}
fn assert_child_harness_filter_keeps_vm_output_and_errors() -> Result<()> {
let output = b"running 1 test\n[vm:linux:stdout] hello\nError: boom\ntest linux_vm_smoke ... FAILED\n\nfailures:\n linux_vm_smoke\n\ntest result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.01s\n";
let mut filtered = Vec::new();
super::write_child_test_output(output, "linux_vm_smoke", &mut filtered)?;
assert_eq!(
String::from_utf8(filtered)?,
"[vm:linux:stdout] hello\nError: boom\n"
);
Ok(())
}
}