Skip to main content

vm_rs/setup/
image.rs

1//! Image catalog, download, and preparation.
2//!
3//! Resolves an `ImageSpec` (distro + version + arch) to download URLs,
4//! fetches and caches the assets, and converts disk formats as needed.
5
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9use sha2::{Digest, Sha256};
10
11use super::SetupError;
12
13// ---------------------------------------------------------------------------
14// Types
15// ---------------------------------------------------------------------------
16
17/// CPU architecture for VM images.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Arch {
20    Aarch64,
21    X86_64,
22}
23
24impl Arch {
25    /// Detect the host architecture.
26    pub fn host() -> Self {
27        if cfg!(target_arch = "aarch64") {
28            Arch::Aarch64
29        } else {
30            Arch::X86_64
31        }
32    }
33}
34
35impl std::fmt::Display for Arch {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        match self {
38            Arch::Aarch64 => write!(f, "aarch64"),
39            Arch::X86_64 => write!(f, "x86_64"),
40        }
41    }
42}
43
44/// What OS image to download and prepare.
45#[derive(Debug, Clone)]
46pub struct ImageSpec {
47    /// Distribution (e.g., "ubuntu", "alpine").
48    pub distro: String,
49    /// Version (e.g., "24.04", "3.20").
50    pub version: String,
51    /// Target architecture. Defaults to host arch if None.
52    pub arch: Option<Arch>,
53}
54
55/// A prepared image — all files needed to boot a VM.
56#[derive(Debug, Clone)]
57#[must_use]
58pub struct PreparedImage {
59    /// Path to the kernel image.
60    pub kernel: PathBuf,
61    /// Path to the initramfs (if the distro provides one).
62    pub initramfs: Option<PathBuf>,
63    /// Path to the root disk image (raw format, ready for CoW cloning).
64    pub disk: PathBuf,
65}
66
67// ---------------------------------------------------------------------------
68// Catalog resolution
69// ---------------------------------------------------------------------------
70
71/// A single downloadable asset with its expected filename.
72#[derive(Debug)]
73struct ImageAsset {
74    filename: &'static str,
75    url: String,
76    source_filename: String,
77    checksum_url: Option<String>,
78}
79
80/// Resolve an image spec to download URLs.
81///
82/// Returns an error if the distro/version/arch combination is not in the catalog.
83/// Users can bring their own images instead.
84fn resolve_image(spec: &ImageSpec) -> Result<Vec<ImageAsset>, SetupError> {
85    let arch = spec.arch.unwrap_or_else(Arch::host);
86    let distro = spec.distro.to_lowercase();
87    let version = &spec.version;
88
89    match distro.as_str() {
90        "ubuntu" => resolve_ubuntu(version, arch),
91        "alpine" => Err(SetupError::UnsupportedImage(
92            "Alpine cloud-init images are not currently supported: the published assets here are \
93             netboot kernel/initramfs plus an ISO, not a writable root disk. Provide your own \
94             root disk image or use Ubuntu."
95                .into(),
96        )),
97        _ => Err(SetupError::UnsupportedImage(format!(
98            "unknown distro '{}'. Supported: ubuntu. \
99             Or bring your own kernel + disk image.",
100            distro
101        ))),
102    }
103}
104
105fn resolve_ubuntu(version: &str, arch: Arch) -> Result<Vec<ImageAsset>, SetupError> {
106    let arch_str = match arch {
107        Arch::Aarch64 => "arm64",
108        Arch::X86_64 => "amd64",
109    };
110
111    let base = format!("https://cloud-images.ubuntu.com/releases/{version}/release");
112    let unpacked = format!("{base}/unpacked");
113
114    Ok(vec![
115        ImageAsset {
116            filename: "vmlinuz",
117            source_filename: format!("ubuntu-{version}-server-cloudimg-{arch_str}-vmlinuz-generic"),
118            url: format!("{unpacked}/ubuntu-{version}-server-cloudimg-{arch_str}-vmlinuz-generic"),
119            checksum_url: Some(format!("{unpacked}/SHA256SUMS")),
120        },
121        ImageAsset {
122            filename: "initramfs",
123            source_filename: format!("ubuntu-{version}-server-cloudimg-{arch_str}-initrd-generic"),
124            url: format!("{unpacked}/ubuntu-{version}-server-cloudimg-{arch_str}-initrd-generic"),
125            checksum_url: Some(format!("{unpacked}/SHA256SUMS")),
126        },
127        ImageAsset {
128            filename: "disk.img",
129            source_filename: format!("ubuntu-{version}-server-cloudimg-{arch_str}.img"),
130            url: format!("{base}/ubuntu-{version}-server-cloudimg-{arch_str}.img"),
131            checksum_url: Some(format!("{base}/SHA256SUMS")),
132        },
133    ])
134}
135
136// ---------------------------------------------------------------------------
137// Image preparation — download, cache, convert
138// ---------------------------------------------------------------------------
139
140/// Download and prepare a VM image. Idempotent — skips cached files.
141///
142/// Returns paths to the kernel, initramfs, and disk image.
143///
144/// The disk image is converted to raw format on macOS (Apple VZ requires raw).
145/// On Linux, QCOW2 is kept as-is (Cloud Hypervisor supports it).
146pub async fn prepare_image(
147    spec: &ImageSpec,
148    cache_dir: &Path,
149) -> Result<PreparedImage, SetupError> {
150    let arch = spec.arch.unwrap_or_else(Arch::host);
151    let image_dir = cache_dir
152        .join("images")
153        .join(&spec.distro)
154        .join(&spec.version)
155        .join(arch.to_string());
156
157    std::fs::create_dir_all(&image_dir).map_err(SetupError::Io)?;
158
159    let assets = resolve_image(spec)?;
160    let client = reqwest::Client::builder()
161        .connect_timeout(std::time::Duration::from_secs(30))
162        .timeout(std::time::Duration::from_secs(600))
163        .build()
164        .map_err(|e| SetupError::AssetDownload(format!("failed to create HTTP client: {}", e)))?;
165    let mut checksum_cache: HashMap<String, HashMap<String, String>> = HashMap::new();
166
167    for asset in &assets {
168        let path = image_dir.join(asset.filename);
169        let expected_sha256 = match asset.checksum_url.as_deref() {
170            Some(checksum_url) => Some(
171                expected_sha256(
172                    &client,
173                    checksum_url,
174                    &asset.source_filename,
175                    &mut checksum_cache,
176                )
177                .await?,
178            ),
179            None => None,
180        };
181
182        if path.exists() && verify_download(&path, expected_sha256.as_deref())? {
183            tracing::debug!(file = %asset.filename, "cached and verified, skipping download");
184            continue;
185        }
186
187        if path.exists() {
188            tracing::warn!(
189                file = %asset.filename,
190                path = %path.display(),
191                "cached asset failed verification; re-downloading"
192            );
193        }
194        tracing::info!(file = %asset.filename, url = %asset.url, "downloading");
195        download_file(&client, &asset.url, &path, expected_sha256.as_deref()).await?;
196    }
197
198    let kernel = image_dir.join("vmlinuz");
199    let initramfs_path = image_dir.join("initramfs");
200    let initramfs = if initramfs_path.exists() {
201        Some(initramfs_path)
202    } else {
203        None
204    };
205
206    // On macOS, Apple VZ needs raw disk images — convert from QCOW2 if needed
207    let disk_downloaded = image_dir.join("disk.img");
208    let disk = if cfg!(target_os = "macos") {
209        let raw_path = image_dir.join("disk.raw");
210        if disk_downloaded.exists() && !raw_path.exists() {
211            convert_to_raw(&disk_downloaded, &raw_path)?;
212        }
213        if raw_path.exists() {
214            raw_path
215        } else {
216            disk_downloaded
217        }
218    } else {
219        disk_downloaded
220    };
221
222    if !kernel.exists() {
223        return Err(SetupError::AssetDownload(format!(
224            "kernel not found after download: {}",
225            kernel.display()
226        )));
227    }
228    if !disk.exists() {
229        return Err(SetupError::AssetDownload(format!(
230            "disk image not found after download: {}",
231            disk.display()
232        )));
233    }
234
235    Ok(PreparedImage {
236        kernel,
237        initramfs,
238        disk,
239    })
240}
241
242// ---------------------------------------------------------------------------
243// Internal helpers
244// ---------------------------------------------------------------------------
245
246/// Convert a QCOW2 disk image to raw format.
247fn convert_to_raw(qcow2: &Path, raw: &Path) -> Result<(), SetupError> {
248    tracing::info!(
249        src = %qcow2.display(),
250        dst = %raw.display(),
251        "converting disk image to raw format"
252    );
253    let output = std::process::Command::new("qemu-img")
254        .args(["convert", "-f", "qcow2", "-O", "raw"])
255        .arg(qcow2)
256        .arg(raw)
257        .output()
258        .map_err(SetupError::Io)?;
259
260    if !output.status.success() {
261        let stderr = String::from_utf8_lossy(&output.stderr);
262        return Err(SetupError::AssetDownload(format!(
263            "qemu-img convert failed (exit {}): {}. \
264             Install qemu-img: brew install qemu (macOS) or apt install qemu-utils (Linux)",
265            output.status,
266            stderr.trim()
267        )));
268    }
269    Ok(())
270}
271
272async fn download_file(
273    client: &reqwest::Client,
274    url: &str,
275    path: &Path,
276    expected_sha256: Option<&str>,
277) -> Result<(), SetupError> {
278    let resp = client.get(url).send().await.map_err(|e| {
279        SetupError::AssetDownload(format!("HTTP request failed for {}: {}", url, e))
280    })?;
281
282    if !resp.status().is_success() {
283        return Err(SetupError::AssetDownload(format!(
284            "HTTP {} for {}",
285            resp.status(),
286            url
287        )));
288    }
289
290    let bytes = resp.bytes().await.map_err(|e| {
291        SetupError::AssetDownload(format!("failed to read response body from {}: {}", url, e))
292    })?;
293
294    if let Some(expected) = expected_sha256 {
295        verify_bytes(&bytes, expected, url)?;
296    }
297
298    // Write to temp file then atomic rename to avoid corrupt files on crash
299    let tmp_path = path.with_extension("tmp");
300    std::fs::write(&tmp_path, &bytes).map_err(SetupError::Io)?;
301    // fsync the file to ensure data is on disk before rename
302    {
303        let f = std::fs::File::open(&tmp_path).map_err(SetupError::Io)?;
304        f.sync_all().map_err(SetupError::Io)?;
305    }
306    std::fs::rename(&tmp_path, path).map_err(SetupError::Io)?;
307    tracing::info!(
308        path = %path.display(),
309        bytes = bytes.len(),
310        "downloaded"
311    );
312    Ok(())
313}
314
315async fn expected_sha256(
316    client: &reqwest::Client,
317    checksum_url: &str,
318    filename: &str,
319    cache: &mut HashMap<String, HashMap<String, String>>,
320) -> Result<String, SetupError> {
321    if !cache.contains_key(checksum_url) {
322        let manifest = fetch_checksum_manifest(client, checksum_url).await?;
323        cache.insert(checksum_url.to_string(), manifest);
324    }
325
326    cache
327        .get(checksum_url)
328        .and_then(|manifest| manifest.get(filename))
329        .cloned()
330        .ok_or_else(|| {
331            SetupError::AssetDownload(format!(
332                "checksum manifest {} does not contain {}",
333                checksum_url, filename
334            ))
335        })
336}
337
338async fn fetch_checksum_manifest(
339    client: &reqwest::Client,
340    checksum_url: &str,
341) -> Result<HashMap<String, String>, SetupError> {
342    let resp = client.get(checksum_url).send().await.map_err(|e| {
343        SetupError::AssetDownload(format!(
344            "failed to fetch checksum manifest {}: {}",
345            checksum_url, e
346        ))
347    })?;
348
349    if !resp.status().is_success() {
350        return Err(SetupError::AssetDownload(format!(
351            "HTTP {} for checksum manifest {}",
352            resp.status(),
353            checksum_url
354        )));
355    }
356
357    let body = resp.text().await.map_err(|e| {
358        SetupError::AssetDownload(format!(
359            "failed to read checksum manifest {}: {}",
360            checksum_url, e
361        ))
362    })?;
363
364    parse_checksum_manifest(&body)
365}
366
367fn parse_checksum_manifest(body: &str) -> Result<HashMap<String, String>, SetupError> {
368    let mut manifest = HashMap::new();
369    for line in body.lines() {
370        let line = line.trim();
371        if line.is_empty() || line.starts_with('#') {
372            continue;
373        }
374        let Some(split_at) = line.find(char::is_whitespace) else {
375            return Err(SetupError::AssetDownload(format!(
376                "malformed checksum line: {}",
377                line
378            )));
379        };
380        let (digest, path) = line.split_at(split_at);
381        let digest = digest.trim();
382        let filename = path.trim().trim_start_matches('*').trim_start_matches("./");
383        if digest.len() == 64 && digest.chars().all(|c| c.is_ascii_hexdigit()) {
384            manifest.insert(filename.to_string(), digest.to_ascii_lowercase());
385        }
386    }
387
388    if manifest.is_empty() {
389        return Err(SetupError::AssetDownload(
390            "checksum manifest did not contain any SHA256 entries".into(),
391        ));
392    }
393
394    Ok(manifest)
395}
396
397fn verify_download(path: &Path, expected_sha256: Option<&str>) -> Result<bool, SetupError> {
398    let Some(expected_sha256) = expected_sha256 else {
399        return Ok(true);
400    };
401    let bytes = std::fs::read(path).map_err(SetupError::Io)?;
402    verify_bytes(&bytes, expected_sha256, &path.display().to_string())?;
403    Ok(true)
404}
405
406fn verify_bytes(bytes: &[u8], expected_sha256: &str, label: &str) -> Result<(), SetupError> {
407    let actual_sha256 = format!("{:x}", Sha256::digest(bytes));
408    if actual_sha256 != expected_sha256 {
409        return Err(SetupError::AssetDownload(format!(
410            "SHA256 mismatch for {}: expected {}, got {}",
411            label, expected_sha256, actual_sha256
412        )));
413    }
414    Ok(())
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    #[test]
422    fn arch_display() {
423        assert_eq!(Arch::Aarch64.to_string(), "aarch64");
424        assert_eq!(Arch::X86_64.to_string(), "x86_64");
425    }
426
427    #[test]
428    fn arch_host_returns_valid() {
429        let arch = Arch::host();
430        assert!(matches!(arch, Arch::Aarch64 | Arch::X86_64));
431    }
432
433    #[test]
434    fn resolve_ubuntu_returns_3_assets() {
435        let spec = ImageSpec {
436            distro: "ubuntu".into(),
437            version: "24.04".into(),
438            arch: Some(Arch::Aarch64),
439        };
440        let assets = resolve_image(&spec).expect("ubuntu assets");
441        assert_eq!(assets.len(), 3);
442        assert_eq!(assets[0].filename, "vmlinuz");
443        assert_eq!(assets[1].filename, "initramfs");
444        assert_eq!(assets[2].filename, "disk.img");
445        assert!(assets[0].url.contains("arm64"));
446    }
447
448    #[test]
449    fn resolve_alpine_is_explicitly_unsupported() {
450        let spec = ImageSpec {
451            distro: "alpine".into(),
452            version: "3.20".into(),
453            arch: Some(Arch::X86_64),
454        };
455        let err = resolve_image(&spec).expect_err("alpine should fail fast");
456        assert!(err.to_string().contains("not currently supported"));
457    }
458
459    #[test]
460    fn resolve_unknown_distro_fails() {
461        let spec = ImageSpec {
462            distro: "fedora".into(),
463            version: "40".into(),
464            arch: None,
465        };
466        let err = resolve_image(&spec)
467            .expect_err("unknown distro should fail")
468            .to_string();
469        assert!(err.contains("fedora"));
470    }
471
472    #[test]
473    fn resolve_case_insensitive() {
474        let spec = ImageSpec {
475            distro: "Ubuntu".into(),
476            version: "24.04".into(),
477            arch: Some(Arch::X86_64),
478        };
479        let assets = resolve_image(&spec).expect("ubuntu assets");
480        assert!(assets[0].url.contains("amd64"));
481    }
482
483    #[test]
484    fn parse_checksum_manifest_supports_coreutils_format() {
485        let manifest = parse_checksum_manifest(
486            "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef *disk.img\n\
487             abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd ./initramfs\n",
488        )
489        .expect("manifest");
490
491        assert_eq!(
492            manifest.get("disk.img"),
493            Some(&"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string())
494        );
495        assert_eq!(
496            manifest.get("initramfs"),
497            Some(&"abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd".to_string())
498        );
499    }
500
501    #[test]
502    fn parse_checksum_manifest_rejects_malformed_lines() {
503        let err = parse_checksum_manifest("not-a-valid-line")
504            .expect_err("malformed manifest should fail")
505            .to_string();
506        assert!(err.contains("malformed checksum line"));
507    }
508
509    #[test]
510    fn verify_bytes_rejects_digest_mismatch() {
511        let err = verify_bytes(b"vm-rs", &"00".repeat(32), "fixture")
512            .expect_err("mismatched digest should fail")
513            .to_string();
514        assert!(err.contains("SHA256 mismatch"));
515    }
516}