//! **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);
}
}