use crate::ovmf::{self, OvmfError};
use crate::persona::{Persona, TpmVersion};
use crate::smbios::{self, SmbiosError};
use crate::swtpm::SwtpmInstance;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use thiserror::Error;
#[derive(Debug, Clone)]
pub struct Invocation {
argv: Vec<String>,
vars_copy: PathBuf,
}
impl Invocation {
pub fn new(
persona: &Persona,
stick: &Path,
work_dir: &Path,
firmware_root: &Path,
swtpm: &SwtpmInstance,
) -> Result<Self, InvocationError> {
if stick.to_string_lossy().contains('\0') {
return Err(InvocationError::StickPathInvalid {
path: stick.to_path_buf(),
reason: "path contains NUL byte; QEMU argv cannot encode it",
});
}
let stick_canon = fs::canonicalize(stick).map_err(|e| InvocationError::StickNotFound {
path: stick.to_path_buf(),
kind: e.to_string(),
})?;
let paths = ovmf::resolve(
persona.secure_boot.ovmf_variant,
persona.secure_boot.custom_keyring.as_deref(),
firmware_root,
)?;
fs::create_dir_all(work_dir).map_err(|e| InvocationError::WorkDirInaccessible {
path: work_dir.to_path_buf(),
kind: e.to_string(),
})?;
let work_root_canon =
fs::canonicalize(work_dir).map_err(|e| InvocationError::WorkDirInaccessible {
path: work_dir.to_path_buf(),
kind: e.to_string(),
})?;
let vars_copy = work_root_canon.join("OVMF_VARS.fd");
fs::copy(&paths.vars_template, &vars_copy).map_err(|e| {
InvocationError::VarsCopyFailed {
from: paths.vars_template.clone(),
to: vars_copy.clone(),
kind: e.to_string(),
}
})?;
let vars_copy_canon =
fs::canonicalize(&vars_copy).map_err(|e| InvocationError::VarsCopyFailed {
from: paths.vars_template.clone(),
to: vars_copy.clone(),
kind: e.to_string(),
})?;
if !vars_copy_canon.starts_with(&work_root_canon) {
return Err(InvocationError::VarsCopyEscapedRoot {
vars_copy: vars_copy_canon,
work_root: work_root_canon,
});
}
let argv = build_argv(persona, &paths.code, &vars_copy_canon, &stick_canon, swtpm)?;
Ok(Self {
argv,
vars_copy: vars_copy_canon,
})
}
#[must_use]
pub fn build(&self) -> Command {
let mut cmd = Command::new("qemu-system-x86_64");
cmd.args(&self.argv);
cmd
}
#[must_use]
pub fn argv(&self) -> &[String] {
&self.argv
}
#[must_use]
pub fn vars_copy(&self) -> &Path {
&self.vars_copy
}
}
pub fn build_argv(
persona: &Persona,
ovmf_code: &Path,
ovmf_vars_copy: &Path,
stick: &Path,
swtpm: &SwtpmInstance,
) -> Result<Vec<String>, InvocationError> {
let mut argv: Vec<String> = Vec::with_capacity(32);
argv.extend([
"-machine".into(),
"q35,smm=on,accel=kvm:tcg".into(),
"-cpu".into(),
"qemu64".into(),
"-m".into(),
"4096".into(),
"-drive".into(),
format!(
"if=pflash,format=raw,unit=0,readonly=on,file={}",
ovmf_code.display()
),
"-drive".into(),
format!(
"if=pflash,format=raw,unit=1,file={}",
ovmf_vars_copy.display()
),
]);
argv.extend([
"-device".into(),
"qemu-xhci,id=xhci".into(),
"-drive".into(),
format!("file={},format=raw,if=none,id=stick", stick.display()),
"-device".into(),
"usb-storage,bus=xhci.0,drive=stick".into(),
]);
if let Some(sock) = swtpm.socket_path() {
argv.extend([
"-chardev".into(),
format!("socket,id=chrtpm,path={}", sock.display()),
"-tpmdev".into(),
"emulator,id=tpm,chardev=chrtpm".into(),
"-device".into(),
tpm_device_for_version(persona.tpm.version).into(),
]);
}
argv.extend(smbios::smbios_argv(&persona.dmi)?);
argv.extend(["-nographic".into(), "-serial".into(), "mon:stdio".into()]);
Ok(argv)
}
fn tpm_device_for_version(v: TpmVersion) -> &'static str {
match v {
TpmVersion::Tpm12 => "tpm-tis,tpmdev=tpm",
TpmVersion::Tpm20 | TpmVersion::None => "tpm-crb,tpmdev=tpm",
}
}
#[derive(Debug, Error)]
pub enum InvocationError {
#[error("stick {path} not found or inaccessible: {kind}")]
StickNotFound {
path: PathBuf,
kind: String,
},
#[error("stick {path} is invalid: {reason}")]
StickPathInvalid {
path: PathBuf,
reason: &'static str,
},
#[error("work dir {path} inaccessible: {kind}")]
WorkDirInaccessible {
path: PathBuf,
kind: String,
},
#[error("copying `OVMF_VARS` from {from} to {to} failed: {kind}")]
VarsCopyFailed {
from: PathBuf,
to: PathBuf,
kind: String,
},
#[error("`OVMF_VARS` copy {vars_copy} escaped work root {work_root}")]
VarsCopyEscapedRoot {
vars_copy: PathBuf,
work_root: PathBuf,
},
#[error(transparent)]
Ovmf(#[from] OvmfError),
#[error(transparent)]
Smbios(#[from] SmbiosError),
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::persona::{Dmi, Kernel, OvmfVariant, SecureBoot, Source, SourceKind, Tpm};
fn tpm20_persona() -> Persona {
Persona {
schema_version: 1,
id: "test".into(),
vendor: "QEMU".into(),
display_name: "test".into(),
year: None,
source: Source {
kind: SourceKind::VendorDocs,
ref_: "test".into(),
captured_at: None,
},
dmi: Dmi {
sys_vendor: "QEMU".into(),
product_name: "Standard PC".into(),
product_version: None,
bios_vendor: "EDK II".into(),
bios_version: "edk2-stable".into(),
bios_date: "01/01/2024".into(),
board_name: None,
chassis_type: None,
},
secure_boot: SecureBoot {
ovmf_variant: OvmfVariant::MsEnrolled,
custom_keyring: None,
},
tpm: Tpm {
version: TpmVersion::Tpm20,
manufacturer: None,
firmware_version: None,
},
kernel: Kernel::default(),
quirks: Vec::new(),
scenarios: std::collections::BTreeMap::new(),
}
}
fn tpm12_persona() -> Persona {
let mut p = tpm20_persona();
p.tpm.version = TpmVersion::Tpm12;
p
}
fn no_tpm_persona() -> Persona {
let mut p = tpm20_persona();
p.tpm.version = TpmVersion::None;
p
}
fn fake_swtpm(version: TpmVersion, work: &Path) -> SwtpmInstance {
let spec = crate::swtpm::SwtpmSpec::derive("test-run", work, version);
if matches!(version, TpmVersion::None) {
return crate::swtpm::SwtpmInstance::NoTpm { spec };
}
crate::swtpm::SwtpmInstance::spawn_with_binary(&spec, "sleep").unwrap()
}
#[test]
fn argv_for_tpm20_persona_emits_crb_device() {
let tmp = tempfile::tempdir().unwrap();
let swtpm = fake_swtpm(TpmVersion::Tpm20, tmp.path());
let argv = build_argv(
&tpm20_persona(),
Path::new("/fake/OVMF_CODE.fd"),
Path::new("/fake/OVMF_VARS.fd"),
Path::new("/fake/stick.img"),
&swtpm,
)
.unwrap();
assert!(argv.iter().any(|a| a == "tpm-crb,tpmdev=tpm"));
assert!(argv.iter().any(|a| a.contains("chrtpm,path=")));
}
#[test]
fn argv_for_tpm12_persona_emits_tis_device() {
let tmp = tempfile::tempdir().unwrap();
let swtpm = fake_swtpm(TpmVersion::Tpm12, tmp.path());
let argv = build_argv(
&tpm12_persona(),
Path::new("/fake/OVMF_CODE.fd"),
Path::new("/fake/OVMF_VARS.fd"),
Path::new("/fake/stick.img"),
&swtpm,
)
.unwrap();
assert!(argv.iter().any(|a| a == "tpm-tis,tpmdev=tpm"));
}
#[test]
fn argv_for_no_tpm_persona_omits_tpm_wiring() {
let tmp = tempfile::tempdir().unwrap();
let swtpm = fake_swtpm(TpmVersion::None, tmp.path());
let argv = build_argv(
&no_tpm_persona(),
Path::new("/fake/OVMF_CODE.fd"),
Path::new("/fake/OVMF_VARS.fd"),
Path::new("/fake/stick.img"),
&swtpm,
)
.unwrap();
assert!(
!argv.iter().any(|a| a.starts_with("tpm-")),
"no TPM device should be present; got {argv:?}"
);
assert!(
!argv.iter().any(|a| a.contains("chrtpm")),
"no TPM chardev should be present"
);
assert!(!argv.iter().any(|a| a == "-tpmdev"));
}
#[test]
fn argv_contains_smm_on_machine() {
let tmp = tempfile::tempdir().unwrap();
let swtpm = fake_swtpm(TpmVersion::Tpm20, tmp.path());
let argv = build_argv(
&tpm20_persona(),
Path::new("/fake/OVMF_CODE.fd"),
Path::new("/fake/OVMF_VARS.fd"),
Path::new("/fake/stick.img"),
&swtpm,
)
.unwrap();
let m = argv
.iter()
.zip(argv.iter().skip(1))
.find(|(a, _)| *a == "-machine")
.map(|(_, v)| v.as_str())
.expect("-machine arg");
assert!(m.contains("smm=on"), "SMM required for Secure Boot");
}
#[test]
fn argv_wires_pflash_code_readonly_and_vars_writable() {
let tmp = tempfile::tempdir().unwrap();
let swtpm = fake_swtpm(TpmVersion::Tpm20, tmp.path());
let argv = build_argv(
&tpm20_persona(),
Path::new("/firmware/OVMF_CODE.fd"),
Path::new("/work/OVMF_VARS.fd"),
Path::new("/fake/stick.img"),
&swtpm,
)
.unwrap();
let joined = argv.join(" ");
assert!(
joined.contains("unit=0,readonly=on,file=/firmware/OVMF_CODE.fd"),
"`OVMF_CODE` must be readonly on pflash unit 0"
);
assert!(
joined.contains("unit=1,file=/work/OVMF_VARS.fd"),
"`OVMF_VARS` must be writable on pflash unit 1"
);
assert!(
!joined.contains("unit=1,readonly=on"),
"`OVMF_VARS` must NOT be readonly (the VM writes SB state here)"
);
}
#[test]
fn argv_includes_stick_as_usb_storage() {
let tmp = tempfile::tempdir().unwrap();
let swtpm = fake_swtpm(TpmVersion::Tpm20, tmp.path());
let argv = build_argv(
&tpm20_persona(),
Path::new("/fake/OVMF_CODE.fd"),
Path::new("/fake/OVMF_VARS.fd"),
Path::new("/flash/aegis-boot.img"),
&swtpm,
)
.unwrap();
let joined = argv.join(" ");
assert!(joined.contains("file=/flash/aegis-boot.img,format=raw,if=none,id=stick"));
assert!(
joined.contains("qemu-xhci,id=xhci"),
"q35 needs explicit USB controller"
);
assert!(joined.contains("usb-storage,bus=xhci.0,drive=stick"));
}
#[test]
fn argv_passes_dmi_through_smbios_blocks() {
let tmp = tempfile::tempdir().unwrap();
let swtpm = fake_swtpm(TpmVersion::Tpm20, tmp.path());
let argv = build_argv(
&tpm20_persona(),
Path::new("/fake/OVMF_CODE.fd"),
Path::new("/fake/OVMF_VARS.fd"),
Path::new("/fake/stick.img"),
&swtpm,
)
.unwrap();
let joined = argv.join(" ");
assert!(joined.contains("type=0,vendor=EDK II"));
assert!(joined.contains("type=1,manufacturer=QEMU,product=Standard PC"));
}
#[test]
fn argv_is_headless() {
let tmp = tempfile::tempdir().unwrap();
let swtpm = fake_swtpm(TpmVersion::None, tmp.path());
let argv = build_argv(
&no_tpm_persona(),
Path::new("/fake/OVMF_CODE.fd"),
Path::new("/fake/OVMF_VARS.fd"),
Path::new("/fake/stick.img"),
&swtpm,
)
.unwrap();
assert!(argv.iter().any(|a| a == "-nographic"));
assert!(argv.iter().any(|a| a == "mon:stdio"));
}
#[test]
fn invocation_new_rejects_nul_in_stick_path() {
let tmp = tempfile::tempdir().unwrap();
let swtpm = fake_swtpm(TpmVersion::Tpm20, tmp.path());
#[cfg(unix)]
{
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt as _;
let bytes = b"/tmp/bad\0stick.img".to_vec();
let stick = PathBuf::from(OsString::from_vec(bytes));
let err = Invocation::new(&tpm20_persona(), &stick, tmp.path(), tmp.path(), &swtpm)
.unwrap_err();
assert!(
matches!(err, InvocationError::StickPathInvalid { .. }),
"expected StickPathInvalid, got {err:?}"
);
}
}
#[test]
fn invocation_new_surfaces_ovmf_missing_as_named_error() {
let tmp = tempfile::tempdir().unwrap();
let swtpm = fake_swtpm(TpmVersion::Tpm20, tmp.path());
let stick = tmp.path().join("stick.img");
fs::write(&stick, b"fake stick").unwrap();
let err =
Invocation::new(&tpm20_persona(), &stick, tmp.path(), tmp.path(), &swtpm).unwrap_err();
assert!(
matches!(
err,
InvocationError::Ovmf(OvmfError::OvmfCodeMissing { .. })
),
"expected Ovmf(OvmfCodeMissing), got {err:?}"
);
}
#[test]
fn invocation_new_happy_path_copies_vars_template() {
let tmp = tempfile::tempdir().unwrap();
let swtpm = fake_swtpm(TpmVersion::Tpm20, tmp.path());
let fw = tmp.path().join("fw");
fs::create_dir_all(&fw).unwrap();
fs::write(fw.join("OVMF_CODE_4M.secboot.fd"), b"code").unwrap();
fs::write(fw.join("OVMF_VARS_4M.ms.fd"), b"vars template").unwrap();
let stick = tmp.path().join("stick.img");
fs::write(&stick, b"fake stick").unwrap();
let work = tmp.path().join("work");
fs::create_dir_all(&work).unwrap();
let inv = Invocation::new(&tpm20_persona(), &stick, &work, &fw, &swtpm).unwrap();
let vars = inv.vars_copy();
assert!(vars.exists());
let contents = fs::read(vars).unwrap();
assert_eq!(contents, b"vars template");
let joined = inv.argv().join(" ");
assert!(joined.contains(&vars.display().to_string()));
}
#[test]
fn build_returns_qemu_command() {
let tmp = tempfile::tempdir().unwrap();
let swtpm = fake_swtpm(TpmVersion::None, tmp.path());
let argv = build_argv(
&no_tpm_persona(),
Path::new("/fake/OVMF_CODE.fd"),
Path::new("/fake/OVMF_VARS.fd"),
Path::new("/fake/stick.img"),
&swtpm,
)
.unwrap();
let inv = Invocation {
argv,
vars_copy: PathBuf::from("/fake/OVMF_VARS.fd"),
};
let cmd = inv.build();
assert_eq!(cmd.get_program().to_string_lossy(), "qemu-system-x86_64");
}
#[test]
#[cfg(unix)]
fn invocation_new_rejects_vars_copy_symlink_escape() {
let tmp = tempfile::tempdir().unwrap();
let fw = tmp.path().join("fw");
fs::create_dir_all(&fw).unwrap();
fs::write(fw.join("OVMF_CODE_4M.secboot.fd"), b"code").unwrap();
fs::write(fw.join("OVMF_VARS_4M.ms.fd"), b"vars template").unwrap();
let stick = tmp.path().join("stick.img");
fs::write(&stick, b"fake stick").unwrap();
let work = tmp.path().join("work");
fs::create_dir_all(&work).unwrap();
let escape_target = tmp.path().join("escape-target.fd");
fs::write(&escape_target, b"would-be victim").unwrap();
std::os::unix::fs::symlink(&escape_target, work.join("OVMF_VARS.fd")).unwrap();
let swtpm = fake_swtpm(TpmVersion::None, tmp.path());
let mut p = tpm20_persona();
p.tpm.version = TpmVersion::None;
let err = Invocation::new(&p, &stick, &work, &fw, &swtpm).unwrap_err();
assert!(
matches!(err, InvocationError::VarsCopyEscapedRoot { .. }),
"expected VarsCopyEscapedRoot, got {err:?}"
);
}
#[test]
fn invocation_new_canonicalizes_vars_copy_through_relative_work_dir() {
let tmp = tempfile::tempdir().unwrap();
let fw = tmp.path().join("fw");
fs::create_dir_all(&fw).unwrap();
fs::write(fw.join("OVMF_CODE_4M.secboot.fd"), b"code").unwrap();
fs::write(fw.join("OVMF_VARS_4M.ms.fd"), b"vars template").unwrap();
let stick = tmp.path().join("stick.img");
fs::write(&stick, b"fake stick").unwrap();
let work = tmp.path().join("work");
fs::create_dir_all(&work).unwrap();
let swtpm = fake_swtpm(TpmVersion::Tpm20, tmp.path());
let inv = Invocation::new(&tpm20_persona(), &stick, &work, &fw, &swtpm).unwrap();
let canonical_work = fs::canonicalize(&work).unwrap();
assert!(inv.vars_copy().is_absolute());
assert!(
inv.vars_copy().starts_with(&canonical_work),
"vars_copy {} should start with canonical work root {}",
inv.vars_copy().display(),
canonical_work.display()
);
}
}