1use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9use sha2::{Digest, Sha256};
10
11use super::SetupError;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Arch {
20 Aarch64,
21 X86_64,
22}
23
24impl Arch {
25 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#[derive(Debug, Clone)]
46pub struct ImageSpec {
47 pub distro: String,
49 pub version: String,
51 pub arch: Option<Arch>,
53}
54
55#[derive(Debug, Clone)]
57#[must_use]
58pub struct PreparedImage {
59 pub kernel: PathBuf,
61 pub initramfs: Option<PathBuf>,
63 pub disk: PathBuf,
65}
66
67#[derive(Debug)]
73struct ImageAsset {
74 filename: &'static str,
75 url: String,
76 source_filename: String,
77 checksum_url: Option<String>,
78}
79
80fn 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
136pub 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 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
242fn 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 let tmp_path = path.with_extension("tmp");
300 std::fs::write(&tmp_path, &bytes).map_err(SetupError::Io)?;
301 {
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}