use std::path::{Path, PathBuf};
use crate::config::{Result, TestbedError, VmProfile};
use crate::qemu::download;
pub mod macos;
pub const MIN_IMAGE_SIZE: u64 = 100 * 1_048_576;
const COMPRESSED_EXTENSIONS: &[&str] = &["qcow2.gz", "qcow2.xz"];
pub fn ensure_image(profile: &VmProfile) -> Result<std::path::PathBuf> {
if profile.name.starts_with("macos") {
return ensure_macos_from_store_or_fallback(profile);
}
let env_key = format!("TESTBED_IMAGE_{}", profile.name.to_uppercase().replace('-', "_"));
if let Ok(image_path) = std::env::var(&env_key) {
let path = PathBuf::from(&image_path);
if !path.exists() {
return Err(TestbedError::DownloadFailed {
status: 0,
url: format!("TESTBED_IMAGE_{} points to non-existent file: {}", env_key, image_path),
});
}
let meta = std::fs::metadata(&path).map_err(|e| TestbedError::Qcow2Error {
message: format!("stat {}: {e}", path.display()),
})?;
if meta.len() < MIN_IMAGE_SIZE {
return Err(TestbedError::Qcow2Error {
message: format!("image at {} too small ({} bytes) — likely corrupted", path.display(), meta.len()),
});
}
if is_compressed_qcow2(&path) {
println!(" Decompressing {} from override path...", path.extension().unwrap_or_default().to_str().unwrap_or("unknown"));
let dest = profile.image_cache_path();
crate::export::decompress(&path, &dest)?;
return Ok(dest);
}
println!(" Using image from override ({}): {}", env_key, path.display());
return Ok(path);
}
let dest = profile.image_cache_path();
if let Some(cached) = find_cached_image(&dest) {
return crate::export::ensure_decompressed(&cached);
}
if let Some(store_path) = try_download_from_store(profile, &dest)? {
return Ok(store_path);
}
let url = resolve_image_url(profile)?;
let temp_dest = dest.with_extension("downloading");
download::download(&url, &temp_dest)?;
if is_compressed_qcow2(&temp_dest) {
println!(" Decompressing {} archive...", temp_dest.extension().unwrap_or_default().to_str().unwrap_or("unknown"));
crate::export::decompress(&temp_dest, &dest)?;
let _ = std::fs::remove_file(&temp_dest);
} else if is_vagrant_box(&temp_dest) {
extract_qcow2_from_box(&temp_dest, &dest)?;
let _ = std::fs::remove_file(&temp_dest);
} else {
std::fs::rename(&temp_dest, &dest).map_err(|e| TestbedError::Qcow2Error {
message: format!("moving downloaded image to {dest:?}: {e}"),
})?;
}
validate_image(&dest)?;
Ok(dest)
}
fn try_download_from_store(profile: &VmProfile, dest: &Path) -> Result<Option<PathBuf>> {
let stores = crate::config::load_image_stores();
if stores.is_empty() {
return Ok(None);
}
let filename = format!("{}.qcow2", profile.name);
for store in &stores {
let Some(url) = store_download_url_direct(store, &filename) else { continue };
let url_path = Path::new(&url);
if url_path.exists() {
println!(" Found image in local store '{}': {}", store.name, url);
std::fs::copy(url_path, dest).map_err(|e| TestbedError::Qcow2Error {
message: format!("copying from local store: {e}"),
})?;
return Ok(Some(dest.to_path_buf()));
}
if url.starts_with("http://") || url.starts_with("https://") {
println!(" Trying store '{}' → {}...", store.name, url);
let temp_dest = dest.with_extension("downloading");
if download::download(&url, &temp_dest).is_ok() {
std::fs::rename(&temp_dest, dest).ok();
if validate_image(dest).is_ok() {
return Ok(Some(dest.to_path_buf()));
}
}
let _ = std::fs::remove_file(&temp_dest);
}
}
Ok(None)
}
fn store_download_url_direct(store: &crate::config::ImageStore, filename: &str) -> Option<String> {
match store.store_type {
crate::config::StoreType::Http => {
Some(format!("{}/{}", store.destination.trim_end_matches('/'), filename))
}
crate::config::StoreType::Local => {
Some(format!("{}/{}", store.destination.trim_end_matches('/'), filename))
}
crate::config::StoreType::R2 | crate::config::StoreType::S3 => {
None
}
}
}
fn find_cached_image(dest: &std::path::Path) -> Option<std::path::PathBuf> {
if download::is_cached(dest) {
return Some(dest.to_path_buf());
}
for ext in COMPRESSED_EXTENSIONS {
let compressed = dest.with_extension(ext);
if compressed.exists() {
let meta = std::fs::metadata(&compressed).ok()?;
if meta.len() > MIN_IMAGE_SIZE {
return Some(compressed);
}
}
}
None
}
fn is_compressed_qcow2(path: &std::path::Path) -> bool {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
match ext {
"gz" | "xz" => true,
_ => false,
}
}
pub fn download_from_url(url: &str, dest: &Path) -> Result<()> {
download::download(url, dest)?;
validate_image(dest)?;
Ok(())
}
pub fn is_cached(profile: &VmProfile) -> bool {
let env_key = format!("TESTBED_IMAGE_{}", profile.name.to_uppercase().replace('-', "_"));
if let Ok(path) = std::env::var(&env_key) {
return PathBuf::from(&path).exists();
}
if profile.name.starts_with("macos") {
let macos_dir = crate::config::image_cache_dir().join(format!("macos-{}", profile.name));
macos_dir.join("BaseSystem.qcow2").exists()
&& macos_dir.join(macos::OPENCORE_EFI_FILENAME).exists()
&& macos_dir.join("macOS-data.qcow2").exists()
} else {
let dest = profile.image_cache_path();
download::is_cached(&dest)
|| COMPRESSED_EXTENSIONS.iter().any(|ext| {
dest.with_extension(ext).exists()
})
}
}
pub fn evict(profile: &VmProfile) -> Result<()> {
let env_key = format!("TESTBED_IMAGE_{}", profile.name.to_uppercase().replace('-', "_"));
if std::env::var(&env_key).is_ok() {
return Ok(());
}
if profile.name.starts_with("macos") {
let macos_dir = crate::config::image_cache_dir().join(format!("macos-{}", profile.name));
if macos_dir.exists() {
std::fs::remove_dir_all(&macos_dir).map_err(|e| TestbedError::Qcow2Error {
message: format!("removing {macos_dir:?}: {e}"),
})?;
}
Ok(())
} else {
let dest = profile.image_cache_path();
let mut removed = false;
if dest.exists() {
std::fs::remove_file(&dest).map_err(|e| TestbedError::Qcow2Error {
message: format!("removing {dest:?}: {e}"),
})?;
removed = true;
}
for ext in COMPRESSED_EXTENSIONS {
let compressed = dest.with_extension(ext);
if compressed.exists() {
std::fs::remove_file(&compressed).map_err(|e| TestbedError::Qcow2Error {
message: format!("removing {compressed:?}: {e}"),
})?;
removed = true;
}
}
if !removed {
}
Ok(())
}
}
pub fn cached_size(profile: &VmProfile) -> Result<u64> {
let env_key = format!("TESTBED_IMAGE_{}", profile.name.to_uppercase().replace('-', "_"));
if let Ok(path) = std::env::var(&env_key) {
let meta = std::fs::metadata(&path).map_err(|e| TestbedError::Qcow2Error {
message: format!("stat {path}: {e}"),
})?;
return Ok(meta.len());
}
let dest = profile.image_cache_path();
let metadata = std::fs::metadata(&dest).map_err(|e| TestbedError::Qcow2Error {
message: format!("stat {dest:?}: {e}"),
})?;
Ok(metadata.len())
}
fn is_vagrant_box(path: &Path) -> bool {
if let Ok(mut f) = std::fs::File::open(path) {
let mut buf = [0u8; 2];
if std::io::Read::read_exact(&mut f, &mut buf).is_ok()
&& buf[0] == 0x1f && buf[1] == 0x8b {
return true;
}
}
std::process::Command::new("tar")
.args(["-tf", path.to_str().unwrap()])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn extract_qcow2_from_box(box_path: &Path, dest: &Path) -> Result<()> {
let output = std::process::Command::new("tar")
.arg("-tf")
.arg(box_path)
.output()
.map_err(|e| TestbedError::Qcow2Error {
message: format!("listing vagrant box contents: {e}"),
})?;
if !output.status.success() {
return Err(TestbedError::Qcow2Error {
message: format!("failed to list vagrant box: {}", String::from_utf8_lossy(&output.stderr)),
});
}
let entries: String = String::from_utf8_lossy(&output.stdout).to_string();
let disk_entry = entries.lines().find(|name| {
let n = name.trim();
n == "box.img" || n == "box_0.img" || n.ends_with(".qcow2") || n.ends_with(".img")
}).ok_or_else(|| TestbedError::Qcow2Error {
message: "no disk image found in vagrant box (expected box.img, box_0.img, *.qcow2, or *.img)".to_string(),
})?;
let temp_extract = dest.with_extension("extracting");
let out_file = std::fs::File::create(&temp_extract).map_err(|e| TestbedError::Qcow2Error {
message: format!("creating {temp_extract:?}: {e}"),
})?;
let status = std::process::Command::new("tar")
.args(["-xf", box_path.to_str().unwrap(), "-O", disk_entry])
.stdout(out_file)
.status()
.map_err(|e| TestbedError::Qcow2Error {
message: format!("extracting disk image: {e}"),
})?;
if !status.success() {
let _ = std::fs::remove_file(&temp_extract);
return Err(TestbedError::Qcow2Error {
message: "failed to extract disk image from vagrant box".to_string(),
});
}
std::fs::rename(&temp_extract, dest).map_err(|e| TestbedError::Qcow2Error {
message: format!("moving extracted disk to {dest:?}: {e}"),
})?;
Ok(())
}
fn resolve_image_url(profile: &VmProfile) -> Result<String> {
if let Some(url) = profile.prebaked_url {
return Ok(url.to_string());
}
if let Some(url) = query_vagrant_cloud(profile) {
return Ok(url);
}
Err(TestbedError::DownloadFailed {
status: 0,
url: format!("no image source for profile '{}'", profile.name),
})
}
fn ensure_macos_from_store_or_fallback(profile: &VmProfile) -> Result<PathBuf> {
let macos_dir = crate::config::image_cache_dir().join(format!("macos-{}", profile.name));
std::fs::create_dir_all(&macos_dir).map_err(|e| TestbedError::Qcow2Error {
message: format!("creating macos image dir: {e}"),
})?;
let basesystem_qcow2 = macos_dir.join("BaseSystem.qcow2");
let opencore_qcow2 = macos_dir.join(macos::OPENCORE_EFI_FILENAME);
let data_disk_qcow2 = macos_dir.join("macOS-data.qcow2");
if basesystem_qcow2.exists() && opencore_qcow2.exists() && data_disk_qcow2.exists()
&& let Ok(meta) = std::fs::metadata(&basesystem_qcow2)
&& meta.len() > 100_000_000 {
return Ok(basesystem_qcow2);
}
if let Some(tarball_url) = resolve_macos_tarball_url(profile) {
eprintln!(" Downloading pre-baked macOS image from store...");
let temp_tar = macos_dir.join(format!("{}.tar.gz", profile.name));
if download::download(&tarball_url, &temp_tar).is_ok()
&& extract_macos_tarball(&temp_tar, &macos_dir).is_ok() {
let _ = std::fs::remove_file(&temp_tar);
if basesystem_qcow2.exists() && opencore_qcow2.exists() && data_disk_qcow2.exists()
&& let Ok(meta) = std::fs::metadata(&basesystem_qcow2)
&& meta.len() > 100_000_000 {
eprintln!(" Pre-baked macOS image extracted successfully.");
return Ok(basesystem_qcow2);
}
eprintln!(" Pre-baked tarball invalid — falling back to native IPSW creation.");
}
let _ = std::fs::remove_file(&temp_tar);
}
eprintln!(" No pre-baked image found — creating macOS image from Apple IPSW...");
macos::ensure_macos_image(profile.name)
}
fn resolve_macos_tarball_url(profile: &VmProfile) -> Option<String> {
let stores = crate::config::load_image_stores();
for store in &stores {
if let Some(url) = store_download_url(store, &format!("{}.tar.gz", profile.name)) {
return Some(url);
}
}
if let Some(url) = profile.prebaked_url {
return Some(url.to_string());
}
None
}
fn store_download_url(store: &crate::config::ImageStore, filename: &str) -> Option<String> {
match store.store_type {
crate::config::StoreType::Http => {
Some(format!("{}/{}", store.destination.trim_end_matches('/'), filename))
}
crate::config::StoreType::Local => {
let local_path = format!("{}/{}", store.destination.trim_end_matches('/'), filename);
if std::path::Path::new(&local_path).exists() {
Some(local_path)
} else {
None
}
}
crate::config::StoreType::R2 | crate::config::StoreType::S3 => {
None
}
}
}
fn extract_macos_tarball(tar_path: &Path, dest_dir: &Path) -> Result<()> {
let output = std::process::Command::new("tar")
.args(["-xzf", tar_path.to_str().unwrap(), "-C", dest_dir.to_str().unwrap()])
.output()
.map_err(|e| TestbedError::Qcow2Error {
message: format!("extracting macOS tarball: {e}"),
})?;
if !output.status.success() {
return Err(TestbedError::Qcow2Error {
message: format!(
"failed to extract macOS tarball: {}",
String::from_utf8_lossy(&output.stderr)
),
});
}
Ok(())
}
fn query_vagrant_cloud(profile: &VmProfile) -> Option<String> {
let vagrant_box = match profile.image_name {
"windows-11-x86_64.qcow2" => Some("gusztavvargadr/windows-11"),
"ubuntu-24.04-x86_64.qcow2" => Some("alvistack/ubuntu-24.04"),
_ => None,
};
let box_name = vagrant_box?;
let api_url = format!("https://app.vagrantup.com/api/v2/box/{box_name}");
let output = std::process::Command::new("curl")
.args(["-s", "-L", "--max-redirs", "5", "-m", "30", &api_url])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let json: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
if let Some(providers) = json.get("current_version")?.get("providers")?.as_array() {
for provider in providers {
if provider.get("name")?.as_str()? == "libvirt" {
if let Some(dl) = provider.get("download_url")?.as_str() {
return Some(dl.to_string());
}
if let Some(url) = provider.get("url")?.as_str() {
return Some(url.to_string());
}
}
}
}
None
}
pub fn validate_image(path: &Path) -> Result<()> {
let metadata = std::fs::metadata(path).map_err(|e| TestbedError::Qcow2Error {
message: format!("stat {path:?}: {e}"),
})?;
let size = metadata.len();
if size < MIN_IMAGE_SIZE {
return Err(TestbedError::Qcow2Error {
message: format!(
"image at {path:?} is only {} bytes (< {} MB minimum) — likely a failed download",
size,
MIN_IMAGE_SIZE / 1_048_576
),
});
}
Ok(())
}
const MIN_VIRTIO_ISO_SIZE: u64 = 500 * 1_048_576;
pub fn ensure_virtio_iso() -> Result<PathBuf> {
let cache_path = crate::config::image_cache_dir().join("virtio-win.iso");
let store_path = Path::new(crate::config::VIRTIO_ISO_STORE_PATH);
if let Ok(meta) = std::fs::metadata(store_path)
&& meta.len() >= MIN_VIRTIO_ISO_SIZE {
return Ok(store_path.to_path_buf());
}
if cache_path.exists()
&& let Ok(meta) = std::fs::metadata(&cache_path)
&& meta.len() >= MIN_VIRTIO_ISO_SIZE {
return Ok(cache_path);
}
eprintln!(" Downloading virtio-win driver ISO...");
std::fs::create_dir_all(cache_path.parent().unwrap()).ok();
let temp_path = cache_path.with_extension("downloading");
download::download(crate::config::VIRTIO_ISO_DOWNLOAD_URL, &temp_path)?;
std::fs::rename(&temp_path, &cache_path).map_err(|e| TestbedError::Qcow2Error {
message: format!("moving virtio ISO to cache: {e}"),
})?;
let meta = std::fs::metadata(&cache_path).map_err(|e| TestbedError::Qcow2Error {
message: format!("stat virtio ISO: {e}"),
})?;
if meta.len() < MIN_VIRTIO_ISO_SIZE {
return Err(TestbedError::Qcow2Error {
message: format!("virtio ISO too small ({} bytes) — download may have failed", meta.len()),
});
}
eprintln!(" virtio-win ISO cached at: {}", cache_path.display());
Ok(cache_path)
}