Gunnar

mikronova/draupnir

src/qemu.rs

//! **draupnir's OWN qemu: the argv IS the machine.**
//!
//! # Why this module exists
//!
//! draupnir is the law's door to a hypervisor — one [`BootSpec`], three backends.
//! But until now the KVM backend built no argv at all: [`crate::kvm`] is, in its
//! own words, "the thin adapter" that translates a draupnir `BootSpec` into
//! `tunnr_vm::BootSpec` and calls `tunnr_vm::boot_test`. `qemu-system` appears
//! nowhere in this crate's `src/` and everywhere in tunnr's. So the **machine
//! shape** — firmware, console, screen, clock — lived in a leaf repo built for
//! kernel work, and asking draupnir for a different machine meant changing tunnr
//! for every caller tunnr has.
//!
//! This module inverts that back: draupnir renders its own argv, from its own
//! spec, and spawns its own QEMU. tunnr keeps doing what tunnr is for (the
//! kernel, buildroot, `boot_test` and its BOOT-OK marker); the `backend-tunnr`
//! path is untouched and still works exactly as before.
//!
//! # The machine a cloud hands out
//!
//! The specification is `mock-upcloud`'s `kvm` module, measured on oden
//! 2026-09-20 against the real `gunnar-installer-open-fullk.iso`:
//!
//! | clause | qemu | behaviour |
//! |---|---|---|
//! | no serial console | `-serial none` | 13 — a PID-1 panic reaches nobody |
//! | a framebuffer, and only that | `-vga std -display none -vnc …` | 13 — the panic IS visible, on the pixels |
//! | SeaBIOS, never OVMF | `-bios …`, no `pflash` | 15 — the BIOS path is the one that breaks |
//! | RTC two hours ahead | `-rtc base=<now+2h>` | 19 — the skew gunnar-clock exists for |
//! | no inbound UDP | user-mode net, `dns=10.0.2.99`, no udp hostfwd | 18 — DNS/NTP useless, replies never arrive |
//! | CD first while inserted | `-boot order=dc` | 17 — the install loop |
//!
//! [`BootSpec::cloud_machine_shape`] sets all of it in one word, on purpose: a
//! guest that got two clauses of the six would be a fourth machine shape nobody
//! measured.
//!
//! # The compatibility promise
//!
//! The emission ORDER here is tunnr's, flag for flag, and every new field's
//! default emits **nothing**. A spec that names none of them renders the argv
//! the `backend-tunnr` path renders for the same spec — pinned as a string
//! literal by [`tests::the_default_spec_renders_the_argv_draupnir_always_built`]
//! and checked against tunnr itself, when that feature is on, by
//! `tests::the_default_argv_is_the_one_tunnr_builds_for_the_same_spec`.

use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};

use crate::{
    BootSpec, Display, Error, Firmware, ImageSource, NetMode, Result, Rtc, SerialConsole,
};

/// The QEMU system binary, overridable for a host that ships it elsewhere.
pub const QEMU_BIN_ENV: &str = "DRAUPNIR_QEMU_BIN";

/// The SeaBIOS ROM Debian/Ubuntu ship, and the one the oden measurement ran.
pub const SEABIOS_ROM: &str = "/usr/share/seabios/bios-256k.bin";

/// The skew every yvra VM boots with, in seconds (+2 h).
pub const CLOUD_RTC_SKEW_SECONDS: i64 = 7200;

/// The DNS address slirp is told to hand the guest so behaviour 18 is real: the
/// query leaves and no reply ever arrives.
pub const CLOUD_DNS: &str = "10.0.2.99";

/// The QEMU binary to launch.
pub fn qemu_binary() -> String {
    std::env::var(QEMU_BIN_ENV).unwrap_or_else(|_| "qemu-system-x86_64".to_string())
}

/// **Pure [`BootSpec`] → argv.** No I/O beyond the firmware resolution the spec
/// itself asked for, no spawn — the seam the tests pin.
///
/// `pflash` is the resolved OVMF `(CODE, per-guest writable VARS)` pair, or
/// `None` for a machine with no UEFI (a `Firmware::Bios` guest, or a
/// `HostDefault` one whose backend supplies its own). `qmp` is the control
/// socket this launch serves, or `None`.
pub fn argv(
    spec: &BootSpec,
    pflash: Option<(&Path, &Path)>,
    qmp: Option<&Path>,
) -> Result<Vec<String>> {
    spec.validate()?;
    let mut args: Vec<String> = vec![
        "-machine".into(),
        format!("{},accel=kvm:tcg", spec.machine.qemu_value()),
        "-m".into(),
        format!("{}M", spec.mem_mb),
        "-smp".into(),
        spec.cores.to_string(),
    ];
    // A guest reboot ends the QEMU (`-no-reboot`), except in the one order whose
    // mechanism IS the reboot: disk first, the CD after it ([`BootOrder::DiskThenMedium`]).
    if spec.boot_order != crate::BootOrder::DiskThenMedium {
        args.push("-no-reboot".into());
    }

    // **The clock.** `Rtc::Host` renders nothing at all, so a spec written before
    // this field is byte-identical; a skew is rendered as an absolute UTC base,
    // because `base=utc` and a relative offset are two different knobs and only
    // the absolute one reproduces "this guest thinks it is two hours later".
    match spec.rtc {
        Rtc::Host => {}
        Rtc::SkewedBy { seconds } => {
            args.push("-rtc".into());
            args.push(format!("base={}", utc_base(now_unix() + seconds)));
        }
    }

    // **The screen.** `Auto` keeps the old meaning — no framebuffer — and the
    // framebuffer mode keeps `-vga std` while dropping only the display.
    match &spec.display {
        Display::Auto => args.push("-nographic".into()),
        Display::Framebuffer { vnc } => {
            args.push("-vga".into());
            args.push("std".into());
            args.push("-display".into());
            args.push("none".into());
            if let Some(v) = vnc {
                args.push("-vnc".into());
                args.push(v.clone());
            }
        }
    }

    // The payload, in tunnr's own order: direct kernel first, then the thing the
    // machine boots.
    let (kernel, payload) = match &spec.image {
        ImageSource::KernelRootfs { kernel, rootfs } => (kernel.clone(), rootfs.clone()),
        ImageSource::Disk { kernel, disk } => (kernel.clone(), disk.clone()),
        ImageSource::BootloaderDisk { disk } => (String::new(), disk.clone()),
        ImageSource::Iso(iso) => (String::new(), iso.clone()),
        other => {
            return Err(Error::Spec(format!(
                "the qemu machine cannot boot {other:?}"
            )))
        }
    };
    if !kernel.is_empty() {
        args.push("-kernel".into());
        args.push(kernel.clone());
    }
    if is_initramfs(&payload) {
        args.push("-initrd".into());
        args.push(payload.clone());
    } else if is_iso(&payload) {
        args.push("-cdrom".into());
        args.push(payload.clone());
        args.push("-boot".into());
        args.push("d".into());
    } else {
        let fmt = if payload.ends_with(".qcow2") {
            "qcow2"
        } else {
            "raw"
        };
        // COW-protect a precious shared base image exactly as the tunnr path
        // does — a self-booting disk is opened `snapshot=on` unless the caller
        // says the writes must survive. A cloud install is exactly such a
        // caller: `cloud_machine_shape` sets `persist_disk`, because an install
        // whose writes went to a throwaway overlay would prove nothing.
        let snap = if matches!(spec.image, ImageSource::BootloaderDisk { .. }) && !spec.persist_disk
        {
            ",snapshot=on"
        } else {
            ""
        };
        args.push("-drive".into());
        args.push(format!("file={payload},if=virtio,format={fmt}{snap}"));
    }

    // **The firmware.** UEFI is two pflash drives; a BIOS machine has NONE, and
    // that exclusivity is the point — a `-bios` beside a pflash pair is not the
    // machine that breaks up there.
    match &spec.firmware {
        Firmware::Bios { rom } => {
            if let Some(rom) = rom {
                args.push("-bios".into());
                args.push(rom.clone());
            }
        }
        _ => {
            if let Some((code, vars)) = pflash {
                args.push("-drive".into());
                args.push(format!(
                    "if=pflash,format=raw,readonly=on,file={}",
                    code.display()
                ));
                args.push("-drive".into());
                args.push(format!("if=pflash,format=raw,file={}", vars.display()));
            }
        }
    }

    // Networking: user-mode virtio-net, the TCP forwards a spec published, and —
    // when asked — the DNS address that makes behaviour 18 real. No UDP forward
    // is ever emitted: inbound UDP does not arrive up there, and a rig that let
    // one through would be a rig where NTP works.
    match spec.net {
        NetMode::None => {
            args.push("-nic".into());
            args.push("none".into());
        }
        _ => {
            let mut netdev = "user,id=n0".to_string();
            let bind = spec.forward_bind.as_deref().unwrap_or("");
            for (host, guest) in spec.tcp_forwards() {
                netdev.push_str(&format!(",hostfwd=tcp:{bind}:{host}-:{guest}"));
            }
            if let Some(dns) = &spec.guest_dns {
                netdev.push_str(&format!(",dns={dns}"));
            }
            args.push("-netdev".into());
            args.push(netdev);
            args.push("-device".into());
            args.push("virtio-net-pci,netdev=n0".into());
        }
    }

    // **The console, or the absence of one.**
    args.push("-serial".into());
    args.push(match spec.serial {
        SerialConsole::Stdio => "mon:stdio".to_string(),
        SerialConsole::None => "none".to_string(),
    });

    if let Some(q) = qmp {
        args.push("-qmp".into());
        args.push(format!("unix:{},server=on,wait=off", q.display()));
    }

    if !kernel.is_empty() {
        args.push("-append".into());
        let prefix = "console=ttyS0 panic=-1";
        args.push(if spec.cmdline.is_empty() {
            prefix.to_string()
        } else {
            format!("{prefix} {}", spec.cmdline)
        });
    }

    // The removable medium and the boot order LAST but one, then the caller's own
    // extras, exactly where the tunnr path put them (QEMU takes the last `-boot`,
    // so a caller can still override).
    args.extend(medium_args(spec));
    args.extend(spec.extra_qemu_args.iter().cloned());
    Ok(args)
}

/// The medium's tray(s) and the boot order — the same rendering
/// [`crate::kvm::KvmBoot::medium_qemu_args`] hands tunnr, so the two paths put
/// the same ISO in the same place.
fn medium_args(spec: &BootSpec) -> Vec<String> {
    let mut args = Vec::new();
    if let Some(iso) = &spec.medium {
        if spec.medium_attach.has_cdrom() {
            args.push("-cdrom".into());
            args.push(iso.clone());
        }
        if spec.medium_attach.has_virtio() {
            args.push("-drive".into());
            args.push(format!("file={iso},if=virtio,format=raw,readonly=on"));
        }
    }
    if let Some(order) = spec.boot_order.qemu_value() {
        args.push("-boot".into());
        args.push(order.into());
    }
    args
}

fn is_initramfs(p: &str) -> bool {
    p.ends_with(".cpio") || p.ends_with(".cpio.gz") || p.ends_with(".img.gz")
}

fn is_iso(p: &str) -> bool {
    p.to_ascii_lowercase().ends_with(".iso")
}

fn now_unix() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0)
}

/// `YYYY-MM-DDTHH:MM:SS` in UTC, the spelling QEMU's `-rtc base=` takes. Written
/// out here rather than pulled in as a date crate: the civil-from-days algorithm
/// is eleven lines and a dependency for eleven lines is a dependency.
pub fn utc_base(unix_secs: i64) -> String {
    let days = unix_secs.div_euclid(86_400);
    let secs = unix_secs.rem_euclid(86_400);
    let (y, m, d) = civil_from_days(days);
    format!(
        "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}",
        secs / 3600,
        (secs % 3600) / 60,
        secs % 60
    )
}

/// Howard Hinnant's `civil_from_days`, the same one `mock-upcloud`'s
/// `guest_clock` carries for the same reason.
fn civil_from_days(z: i64) -> (i64, u32, u32) {
    let z = z + 719_468;
    let era = z.div_euclid(146_097);
    let doe = z.rem_euclid(146_097);
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
    let y = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
    (if m <= 2 { y + 1 } else { y }, m, d)
}

// ── the live machine ────────────────────────────────────────────────────────

/// A QEMU this crate started, and the things that can be asked of it.
///
/// No reader thread and no monitor thread: the guest's stdout and stderr go to
/// FILES in the workdir (which is also how "zero bytes reached the host" becomes
/// a number rather than an impression), and liveness is `Child::try_wait`. LAW
/// #0 — every thread in the constellation comes from gatling — is honoured by
/// there being none.
pub struct QemuGuest {
    name: String,
    workdir: PathBuf,
    child: Child,
    argv: Vec<String>,
    // Read only by the QMP verbs, which need `backend-qemu` (serde_json).
    #[cfg_attr(not(feature = "backend-qemu"), allow(dead_code))]
    qmp: PathBuf,
    stdout: PathBuf,
    stderr: PathBuf,
}

impl QemuGuest {
    /// **Start the machine this spec describes.** `workdir` holds the QMP socket,
    /// the captured streams and any screendumps; it is created if absent.
    pub fn start(spec: &BootSpec, workdir: &Path) -> Result<QemuGuest> {
        std::fs::create_dir_all(workdir).map_err(|e| Error::Backend(e.to_string()))?;
        let qemu = qemu_binary();
        if which(&qemu).is_none() {
            return Err(Error::Unsupported(format!(
                "QEMU is not on PATH (`{qemu}`): install qemu-system-x86, or set {QEMU_BIN_ENV}"
            )));
        }
        // Fail by name before anything is launched: a medium or a disk that is not
        // on disk is a build that did not happen, not a boot that did not come up.
        for (what, p) in [
            ("medium", spec.medium_path().map(|s| s.to_string())),
            ("image", spec.image_path()),
        ] {
            if let Some(p) = p {
                if !Path::new(&p).exists() {
                    return Err(Error::Spec(format!("{what} not found: {p}")));
                }
            }
        }
        let pflash = stage_pflash(spec, workdir)?;
        let qmp = workdir.join("qmp.sock");
        let _ = std::fs::remove_file(&qmp);
        let args = argv(
            spec,
            pflash.as_ref().map(|(c, v)| (c.as_path(), v.as_path())),
            Some(&qmp),
        )?;
        let stdout = workdir.join("stdout.log");
        let stderr = workdir.join("stderr.log");
        let out = std::fs::File::create(&stdout).map_err(|e| Error::Backend(e.to_string()))?;
        let err = std::fs::File::create(&stderr).map_err(|e| Error::Backend(e.to_string()))?;
        let child = Command::new(&qemu)
            .args(&args)
            .stdin(Stdio::null())
            .stdout(Stdio::from(out))
            .stderr(Stdio::from(err))
            .spawn()
            .map_err(|e| Error::Backend(format!("spawning {qemu}: {e}")))?;
        Ok(QemuGuest {
            name: spec.name.clone(),
            workdir: workdir.to_path_buf(),
            child,
            argv: args,
            qmp,
            stdout,
            stderr,
        })
    }

    pub fn name(&self) -> &str {
        &self.name
    }
    pub fn pid(&self) -> u32 {
        self.child.id()
    }
    /// The exact argv this guest was launched with — the machine, in words.
    pub fn argv(&self) -> &[String] {
        &self.argv
    }
    pub fn workdir(&self) -> &Path {
        &self.workdir
    }

    /// **How many bytes the guest put on stdout.** On a `-serial none` machine the
    /// answer must be zero, and the whole point of behaviour 13 is that it is.
    pub fn stdout_bytes(&self) -> u64 {
        std::fs::metadata(&self.stdout).map(|m| m.len()).unwrap_or(0)
    }

    /// Bytes on stderr — QEMU's own complaints, not the guest's.
    pub fn stderr_text(&self) -> String {
        std::fs::read_to_string(&self.stderr).unwrap_or_default()
    }

    /// Is the QEMU still alive? (Reaps it if not.)
    pub fn is_running(&mut self) -> bool {
        matches!(self.child.try_wait(), Ok(None))
    }

    /// **Photograph the framebuffer.** QMP `screendump` writes a PPM — on a
    /// machine with no serial console this is the ONLY evidence there is, which
    /// is why it is a first-class verb here and not a debugging aid.
    #[cfg(feature = "backend-qemu")]
    pub fn screendump(&self, to: &Path) -> Result<()> {
        let _ = std::fs::remove_file(to);
        self.qmp_execute(
            "screendump",
            Some(serde_json::json!({ "filename": to.display().to_string() })),
        )?;
        // QEMU writes the file during the command, so its absence afterwards is a
        // failure to report rather than a race to sleep through.
        if !to.exists() {
            return Err(Error::Backend(format!(
                "screendump: QEMU returned success but wrote no file at {}",
                to.display()
            )));
        }
        Ok(())
    }

    /// One QMP command, with the capability handshake done on the spot. A fresh
    /// connection per command: the guest is long-lived and a held socket is a
    /// thing to leak.
    #[cfg(feature = "backend-qemu")]
    pub fn qmp_execute(
        &self,
        command: &str,
        arguments: Option<serde_json::Value>,
    ) -> Result<serde_json::Value> {
        use std::io::{BufRead, BufReader};
        use std::os::unix::net::UnixStream;
        let sock = UnixStream::connect(&self.qmp)
            .map_err(|e| Error::Backend(format!("qmp connect {}: {e}", self.qmp.display())))?;
        sock.set_read_timeout(Some(Duration::from_secs(10)))
            .map_err(|e| Error::Backend(e.to_string()))?;
        let mut w = sock.try_clone().map_err(|e| Error::Backend(e.to_string()))?;
        let mut r = BufReader::new(sock);
        // The greeting.
        let mut line = String::new();
        r.read_line(&mut line).map_err(|e| Error::Backend(e.to_string()))?;
        writeln!(w, "{{\"execute\":\"qmp_capabilities\"}}").map_err(|e| Error::Backend(e.to_string()))?;
        read_return(&mut r)?;
        let mut msg = serde_json::json!({ "execute": command });
        if let Some(a) = arguments {
            msg["arguments"] = a;
        }
        writeln!(w, "{msg}").map_err(|e| Error::Backend(e.to_string()))?;
        read_return(&mut r)
    }

    /// **Power the guest down the way a machine powers down**: an ACPI power
    /// button over QMP, `grace` to act on it, then a kill so the QEMU is always
    /// gone when this returns.
    pub fn power_off(&mut self, grace: Duration) -> Result<()> {
        #[cfg(feature = "backend-qemu")]
        let _ = self.qmp_execute("system_powerdown", None);
        let deadline = Instant::now() + grace;
        while Instant::now() < deadline {
            if !self.is_running() {
                return Ok(());
            }
            std::thread::sleep(Duration::from_millis(100));
        }
        self.kill()
    }

    /// Tear the machine down now.
    pub fn kill(&mut self) -> Result<()> {
        let _ = self.child.kill();
        let _ = self.child.wait();
        Ok(())
    }
}

impl Drop for QemuGuest {
    fn drop(&mut self) {
        // A dropped handle must not leave a QEMU holding a disk image: the rig
        // that leaked one spent an afternoon on a "corrupt" qcow2 that was
        // merely still open.
        let _ = self.child.kill();
        let _ = self.child.wait();
    }
}

#[cfg(feature = "backend-qemu")]
fn read_return(r: &mut impl std::io::BufRead) -> Result<serde_json::Value> {
    loop {
        let mut line = String::new();
        let n = r.read_line(&mut line).map_err(|e| Error::Backend(e.to_string()))?;
        if n == 0 {
            return Err(Error::Backend("qmp: the socket closed mid-command".into()));
        }
        let v: serde_json::Value = match serde_json::from_str(&line) {
            Ok(v) => v,
            Err(_) => continue,
        };
        if let Some(e) = v.get("error") {
            return Err(Error::Backend(format!("qmp: {e}")));
        }
        if v.get("return").is_some() {
            return Ok(v);
        }
        // Anything else is an asynchronous EVENT; keep reading.
    }
}


/// Resolve and stage this spec's UEFI pair, or `None` for a machine with no
/// pflash. The VARS copy is per-guest, always: UEFI NVRAM is written during boot
/// and a shared template must never be mutated.
fn stage_pflash(spec: &BootSpec, workdir: &Path) -> Result<Option<(PathBuf, PathBuf)>> {
    let Some((code, vars_tmpl)) = spec.firmware.resolve_in(&Firmware::search_dirs())? else {
        return Ok(None);
    };
    let vars = workdir.join("OVMF_VARS.fd");
    std::fs::copy(&vars_tmpl, &vars).map_err(|e| Error::Backend(e.to_string()))?;
    Ok(Some((code, vars)))
}

fn which(bin: &str) -> Option<PathBuf> {
    if bin.contains('/') {
        let p = PathBuf::from(bin);
        return p.is_file().then_some(p);
    }
    std::env::var_os("PATH").and_then(|paths| {
        std::env::split_paths(&paths)
            .map(|d| d.join(bin))
            .find(|c| c.is_file())
    })
}

// ── the disk, while the machine is stopped ──────────────────────────────────

/// **Grow a qcow2's virtual size, in place, while nothing has it open.**
///
/// This is what a cloud's `resize` does to a stopped server, and it is a header
/// edit: the `size` field at offset 24. Pure Rust, no `qemu-img` subprocess —
/// the law's zero-shell rule applies to the disk as much as to the boot.
///
/// It **REFUSES BY NAME** (`qcow2-l1-too-small`) when the new size needs more L1
/// entries than the image has, rather than writing a header the format cannot
/// back: an image whose L1 table cannot address the space its header claims is a
/// silently corrupt image, and a silent corruption is worse than a refusal a
/// caller can read. Shrinking is refused for the same reason — it would orphan
/// clusters this function does not walk.
pub fn grow_qcow2(path: &Path, new_size_bytes: u64) -> Result<u64> {
    use std::fs::OpenOptions;
    use std::io::{Seek, SeekFrom};
    let mut f = OpenOptions::new()
        .read(true)
        .write(true)
        .open(path)
        .map_err(|e| Error::Backend(format!("{}: {e}", path.display())))?;
    let mut head = [0u8; 72];
    f.read_exact(&mut head)
        .map_err(|e| Error::Backend(format!("{}: {e}", path.display())))?;
    if &head[0..4] != b"QFI\xfb" {
        return Err(Error::Spec(format!(
            "{} is not a qcow2 image (bad magic)",
            path.display()
        )));
    }
    let cluster_bits = u32::from_be_bytes(head[20..24].try_into().unwrap());
    let old_size = u64::from_be_bytes(head[24..32].try_into().unwrap());
    let l1_size = u32::from_be_bytes(head[36..40].try_into().unwrap()) as u64;
    if new_size_bytes < old_size {
        return Err(Error::Spec(format!(
            "qcow2-shrink-refused: {} is {old_size} bytes and {new_size_bytes} is smaller; \
             a shrink would orphan clusters this writer does not walk",
            path.display()
        )));
    }
    let cluster = 1u64 << cluster_bits;
    // One L2 table fills a cluster of 8-byte entries; each entry maps one cluster.
    let per_l1 = (cluster / 8) * cluster;
    let needed = new_size_bytes.div_ceil(per_l1);
    if needed > l1_size {
        // The L1 table cannot address the new size, so it has to move and grow:
        // a bigger table is appended at the end of the file, the old entries are
        // copied into it, the rest are zero (unallocated), and the header points
        // at the new one. Writing the size alone would produce an image whose
        // header is a lie, which is worse than a refusal.
        grow_l1_table(&mut f, &head, needed)?;
    }
    f.seek(SeekFrom::Start(24))
        .map_err(|e| Error::Backend(e.to_string()))?;
    f.write_all(&new_size_bytes.to_be_bytes())
        .map_err(|e| Error::Backend(e.to_string()))?;
    f.flush().map_err(|e| Error::Backend(e.to_string()))?;
    Ok(old_size)
}

/// Move this image's L1 table to a bigger one at the end of the file.
///
/// The new clusters have to be **refcounted**, or the next writer allocates on
/// top of the table it is using. Only the first refcount block is walked here,
/// so an image big enough to need a second one is REFUSED BY NAME
/// (`qcow2-refcount-block-full`) rather than silently left with clusters nothing
/// accounts for.
fn grow_l1_table(f: &mut std::fs::File, head: &[u8; 72], entries: u64) -> Result<()> {
    use std::io::{Seek, SeekFrom};
    let cluster_bits = u32::from_be_bytes(head[20..24].try_into().unwrap());
    let cluster = 1u64 << cluster_bits;
    let old_l1_size = u32::from_be_bytes(head[36..40].try_into().unwrap()) as u64;
    let old_l1_off = u64::from_be_bytes(head[40..48].try_into().unwrap());
    let rt_off = u64::from_be_bytes(head[48..56].try_into().unwrap());

    let mut old = vec![0u8; (old_l1_size * 8) as usize];
    f.seek(SeekFrom::Start(old_l1_off))
        .map_err(|e| Error::Backend(e.to_string()))?;
    f.read_exact(&mut old)
        .map_err(|e| Error::Backend(e.to_string()))?;

    // Append the new table, cluster-aligned, at the end of the file.
    let end = f
        .seek(SeekFrom::End(0))
        .map_err(|e| Error::Backend(e.to_string()))?;
    let new_off = end.div_ceil(cluster) * cluster;
    let new_clusters = (entries * 8).div_ceil(cluster);
    let mut table = vec![0u8; (new_clusters * cluster) as usize];
    table[..old.len()].copy_from_slice(&old);
    f.seek(SeekFrom::Start(new_off))
        .map_err(|e| Error::Backend(e.to_string()))?;
    f.write_all(&table)
        .map_err(|e| Error::Backend(e.to_string()))?;

    // Refcount the clusters the table now occupies, in the FIRST refcount block.
    let mut rt0 = [0u8; 8];
    f.seek(SeekFrom::Start(rt_off))
        .map_err(|e| Error::Backend(e.to_string()))?;
    f.read_exact(&mut rt0)
        .map_err(|e| Error::Backend(e.to_string()))?;
    let rb_off = u64::from_be_bytes(rt0) & !0x1ffu64;
    let per_block = cluster / 2; // 16-bit refcounts
    let first = new_off / cluster;
    if rb_off == 0 || first + new_clusters > per_block {
        return Err(Error::Unsupported(format!(
            "qcow2-refcount-block-full: this image needs a refcount block past the first \
             (cluster {first}+{new_clusters} of {per_block}); growing it is not implemented \
             here, and leaving the new L1 table unaccounted would let the next writer \
             allocate on top of it"
        )));
    }
    for i in 0..new_clusters {
        let at = rb_off + (first + i) * 2;
        f.seek(SeekFrom::Start(at))
            .map_err(|e| Error::Backend(e.to_string()))?;
        f.write_all(&1u16.to_be_bytes())
            .map_err(|e| Error::Backend(e.to_string()))?;
    }

    // Header: the new table's size and offset, in that order.
    f.seek(SeekFrom::Start(36))
        .map_err(|e| Error::Backend(e.to_string()))?;
    f.write_all(&(entries as u32).to_be_bytes())
        .map_err(|e| Error::Backend(e.to_string()))?;
    f.write_all(&new_off.to_be_bytes())
        .map_err(|e| Error::Backend(e.to_string()))?;
    Ok(())
}

/// The **virtual** size a qcow2 header claims, which is not its size on disk.
pub fn qcow2_virtual_size(path: &Path) -> Result<u64> {
    let mut f = std::fs::File::open(path).map_err(|e| Error::Backend(e.to_string()))?;
    let mut head = [0u8; 32];
    f.read_exact(&mut head).map_err(|e| Error::Backend(e.to_string()))?;
    if &head[0..4] != b"QFI\xfb" {
        return Err(Error::Spec(format!(
            "{} is not a qcow2 image (bad magic)",
            path.display()
        )));
    }
    Ok(u64::from_be_bytes(head[24..32].try_into().unwrap()))
}

/// **Author an empty qcow2**, so a rig needs no `qemu-img create` either. Version
/// 3, 64 KiB clusters, an L1 table big enough for `size_bytes`, refcount table
/// and block, and nothing allocated beyond them.
pub fn create_qcow2(path: &Path, size_bytes: u64) -> Result<()> {
    const CLUSTER_BITS: u32 = 16;
    let cluster: u64 = 1 << CLUSTER_BITS;
    let per_l1 = (cluster / 8) * cluster;
    let l1_entries = size_bytes.div_ceil(per_l1).max(1);
    // Layout: [0] header cluster, [1] refcount table, [2] refcount block,
    // [3..] L1 table.
    let refcount_table_off = cluster;
    let refcount_block_off = cluster * 2;
    let l1_off = cluster * 3;
    let l1_clusters = (l1_entries * 8).div_ceil(cluster);
    let total = l1_off + l1_clusters * cluster;

    let mut img = vec![0u8; total as usize];
    // Header.
    img[0..4].copy_from_slice(b"QFI\xfb");
    img[4..8].copy_from_slice(&3u32.to_be_bytes()); // version 3
    img[20..24].copy_from_slice(&CLUSTER_BITS.to_be_bytes());
    img[24..32].copy_from_slice(&size_bytes.to_be_bytes());
    img[32..36].copy_from_slice(&0u32.to_be_bytes()); // crypt_method none
    img[36..40].copy_from_slice(&(l1_entries as u32).to_be_bytes());
    img[40..48].copy_from_slice(&l1_off.to_be_bytes());
    img[48..56].copy_from_slice(&refcount_table_off.to_be_bytes());
    img[56..60].copy_from_slice(&1u32.to_be_bytes()); // refcount_table_clusters
    img[60..64].copy_from_slice(&0u32.to_be_bytes()); // nb_snapshots
    img[64..72].copy_from_slice(&0u64.to_be_bytes()); // snapshots_offset
    img[72..80].copy_from_slice(&0u64.to_be_bytes()); // incompatible_features
    img[80..88].copy_from_slice(&0u64.to_be_bytes()); // compatible_features
    img[88..96].copy_from_slice(&0u64.to_be_bytes()); // autoclear_features
    img[96..100].copy_from_slice(&4u32.to_be_bytes()); // refcount_order (16 bit)
    img[100..104].copy_from_slice(&104u32.to_be_bytes()); // header_length
                                                          // Refcount table: one entry pointing at the block.
    let rt = refcount_table_off as usize;
    img[rt..rt + 8].copy_from_slice(&refcount_block_off.to_be_bytes());
    // Refcount block: refcount 1 for every cluster that exists so far.
    let rb = refcount_block_off as usize;
    for i in 0..(total / cluster) as usize {
        img[rb + i * 2..rb + i * 2 + 2].copy_from_slice(&1u16.to_be_bytes());
    }
    std::fs::write(path, &img).map_err(|e| Error::Backend(format!("{}: {e}", path.display())))?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn cloud_disk_spec() -> BootSpec {
        let mut s = BootSpec::kvm_bootloader_disk("appliance", "/vm/disk.qcow2");
        s.mem_mb = 2048;
        s.cores = 2;
        s
    }

    /// **RED-when-broken — the no-op guarantee.** A spec that names none of the
    /// machine-shape fields renders exactly the argv this crate handed tunnr
    /// before they existed: `-nographic`, a serial console on stdio, no `-rtc`,
    /// no `-vga`, no `-bios`, no `dns=`. The whole argv is pinned as a literal,
    /// not spot-checked, so any drift is red here — which is the only honest way
    /// to claim "existing callers unchanged".
    ///
    /// Flip any of the four defaults (`SerialConsole::Stdio`, `Display::Auto`,
    /// `Rtc::Host`, `guest_dns: None`) in `BootSpec::of` and this goes red.
    #[test]
    fn the_default_spec_renders_the_argv_draupnir_always_built() {
        let s = cloud_disk_spec();
        assert!(
            s.serial == SerialConsole::Stdio
                && s.display == Display::Auto
                && s.rtc == Rtc::Host
                && s.guest_dns.is_none()
                && s.firmware == Firmware::HostDefault,
            "the defaults are yesterday's machine"
        );
        let argv = argv(&s, None, None).unwrap();
        let expected: Vec<String> = [
            "-machine",
            "q35,accel=kvm:tcg",
            "-m",
            "2048M",
            "-smp",
            "2",
            "-no-reboot",
            "-nographic",
            "-drive",
            "file=/vm/disk.qcow2,if=virtio,format=qcow2,snapshot=on",
            "-netdev",
            "user,id=n0",
            "-device",
            "virtio-net-pci,netdev=n0",
            "-serial",
            "mon:stdio",
        ]
        .iter()
        .map(|s| s.to_string())
        .collect();
        assert_eq!(argv, expected, "a default spec's machine moved");
    }

    /// **RED-when-broken — the machine UpCloud hands out**, clause by clause.
    /// The two NEGATIVES are the load-bearing ones: a pflash line means the guest
    /// booted UEFI after all, and `mon:stdio` means a panic that must be
    /// invisible had somewhere to print.
    #[test]
    fn the_cloud_machine_shape_is_every_clause_at_once() {
        let mut s = cloud_disk_spec();
        s.medium = Some("/vm/gunnar.iso".into());
        s = s.cloud_machine_shape(Some("127.0.0.1:91".into()));
        let argv = argv(
            &s,
            Some((Path::new("/fw/CODE.fd"), Path::new("/vm/VARS.fd"))),
            None,
        )
        .unwrap();
        let j = argv.join(" ");
        // 15 — SeaBIOS, never OVMF.
        assert!(!j.contains("pflash") && !j.contains("OVMF"), "{j}");
        assert!(j.contains(&format!("-bios {SEABIOS_ROM}")), "{j}");
        // 13 — no serial console, and a framebuffer that is the only witness.
        assert!(j.contains("-serial none") && !j.contains("mon:stdio"), "{j}");
        assert!(j.contains("-vga std -display none -vnc 127.0.0.1:91"), "{j}");
        assert!(!j.contains("-nographic"), "{j}");
        // 19 — two hours ahead.
        assert!(j.contains("-rtc base="), "{j}");
        // 18 — the DNS that answers nothing, and not one udp forward.
        assert!(j.contains(&format!("dns={CLOUD_DNS}")), "{j}");
        assert!(!j.contains("hostfwd=udp"), "{j}");
        // 17 — the CD first while it is inserted.
        assert!(j.contains("-cdrom /vm/gunnar.iso"), "{j}");
        assert!(j.contains("-boot order=dc"), "{j}");
        // L46 — i440fx, so `-cdrom` is a PIIX IDE drive, not AHCI.
        assert_eq!(&argv[..2], ["-machine", "pc,accel=kvm:tcg"], "{j}");
        assert!(!j.contains("q35"), "{j}");
    }

    /// **Disk first, the CD after it** (the blank-disk install, 2026-09-26):
    /// `-boot order=cd`, and NOT `-no-reboot` — the installer's reboot is a reset
    /// in place and the firmware reads the order again, so the now-installed disk
    /// boots. Every other order keeps `-no-reboot`.
    #[test]
    fn disk_then_medium_boots_the_disk_first_and_reboots_in_place() {
        let mut s = cloud_disk_spec();
        s.medium = Some("/vm/gunnar.iso".into());
        let s = s.cloud_machine_shape(None).with_boot_order(crate::BootOrder::DiskThenMedium);
        let j = argv(&s, None, None).unwrap().join(" ");
        assert!(j.contains("-boot order=cd") && !j.contains("order=dc"), "{j}");
        assert!(!j.contains("-no-reboot"), "{j}");
        let mut empty = cloud_disk_spec().cloud_machine_shape(None).with_boot_order(crate::BootOrder::DiskThenMedium);
        empty.medium = None;
        assert!(argv(&empty, None, None).is_err(), "no medium, no fall-through");
        let mut dc = cloud_disk_spec();
        dc.medium = Some("/vm/gunnar.iso".into());
        let j = argv(&dc.cloud_machine_shape(None), None, None).unwrap().join(" ");
        assert!(j.contains("-no-reboot"), "{j}");
    }

    /// **q35 stays selectable** after the cloud shape, for a test that wants

    /// the other chipset on purpose — and it is the only thing that moves.
    #[test]
    fn q35_is_still_selectable_on_the_cloud_shape() {
        let mut s = cloud_disk_spec();
        s.medium = Some("/vm/gunnar.iso".into());
        let pc = s.clone().cloud_machine_shape(None);
        let mut q = pc.clone();
        q.machine = crate::MachineType::Q35;
        let (a, b) = (argv(&pc, None, None).unwrap(), argv(&q, None, None).unwrap());
        assert_eq!(a[1], "pc,accel=kvm:tcg");
        assert_eq!(b[1], "q35,accel=kvm:tcg");
        assert_eq!(a[2..], b[2..], "only the chipset moved");
    }

    /// **A forward bound to loopback, only when asked.** No `forward_bind`
    /// renders `hostfwd=tcp::H-:G` as it always did; `127.0.0.1` pins it there.
    #[test]
    fn a_forward_binds_loopback_only_when_asked() {
        let mut s = cloud_disk_spec();
        s.port_maps.push(crate::PortMap::new(42222, 2222));
        let j = argv(&s, None, None).unwrap().join(" ");
        assert!(j.contains("hostfwd=tcp::42222-:2222"), "{j}");
        s.forward_bind = Some("127.0.0.1".into());
        let j = argv(&s, None, None).unwrap().join(" ");
        assert!(j.contains("hostfwd=tcp:127.0.0.1:42222-:2222") && !j.contains("tcp::42222"), "{j}");
    }

    /// `-rtc base=` is rendered as a UTC wall clock two hours ahead, not as an
    /// offset QEMU would read as something else.
    #[test]
    fn the_rtc_base_is_an_absolute_utc_timestamp() {
        assert_eq!(utc_base(0), "1970-01-01T00:00:00");
        // 2026-09-20T12:00:00Z + 2 h
        let t = utc_base(1_789_646_400 + 7200);
        assert!(t.starts_with("2026-"), "{t}");
        assert_eq!(t.len(), 19);
    }

    /// A qcow2 this crate authored is one this crate can grow, and the growth is
    /// a header edit a reader sees.
    #[test]
    fn a_qcow2_grows_by_its_header_and_refuses_what_it_cannot_back() {
        let dir = std::env::temp_dir().join(format!("draupnir-qcow2-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let p = dir.join("disk.qcow2");
        create_qcow2(&p, 10 << 30).unwrap();
        assert_eq!(qcow2_virtual_size(&p).unwrap(), 10 << 30);
        let was = grow_qcow2(&p, 20 << 30).unwrap();
        assert_eq!(was, 10 << 30);
        assert_eq!(qcow2_virtual_size(&p).unwrap(), 20 << 30);
        // The L1 table really grew with it — a header that claimed 20 GiB over a
        // table that could address 10 would be an image whose size is a lie.
        let mut h = [0u8; 48];
        std::io::Read::read_exact(&mut std::fs::File::open(&p).unwrap(), &mut h).unwrap();
        let l1 = u32::from_be_bytes(h[36..40].try_into().unwrap()) as u64;
        assert!(l1 >= (20u64 << 30).div_ceil(512 << 20), "l1 = {l1}");
        // A shrink is refused by name rather than silently orphaning clusters.
        let err = grow_qcow2(&p, 1 << 30).unwrap_err();
        assert!(format!("{err}").contains("qcow2-shrink-refused"), "{err}");
        let _ = std::fs::remove_dir_all(&dir);
    }
}