use std::{
ffi::OsString,
io::{self, ErrorKind},
path::Path,
path::PathBuf,
process::Stdio,
sync::{Arc, Mutex},
time::Instant,
};
use anyhow::{Context, anyhow};
#[cfg(windows)]
use colored::Colorize;
use object::Architecture;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use tokio::{
fs,
io::{AsyncRead, AsyncReadExt, AsyncWriteExt},
process::Command as TokioCommand,
sync::mpsc,
};
use crate::{
artifact::state::OutputArtifacts,
boot::artifacts::{default_qemu_dtb_dump_path, prepare_qemu_dtb_dump},
build::config::Cargo,
invocation::Invocation,
process::ProcessContext,
project::variables::{self, VariableScope},
project::{ProjectLayout, metadata},
run::{
execution::{RunnerExecutionSummary, RunnerExitStatus, timeout_duration},
output_matcher::{ByteStreamMatcher, compile_regexes, print_match_event},
ovmf_prebuilt::{Arch, FileType, Prebuilt, Source},
qemu_plan::{QemuBootSource, QemuCommandPlanInput, build_qemu_command_plan},
shell_init::{SHELL_INIT_DELAY, ShellAutoInitMatcher, normalize_shell_init_config},
},
sterm::{AsyncTerminal, TerminalConfig},
utils::PathResultExt,
};
enum UefiBootConfig {
Pflash {
code: PathBuf,
vars: PathBuf,
esp_dir: PathBuf,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
pub struct QemuConfig {
pub args: Vec<String>,
pub uefi: bool,
#[serde(default)]
pub to_bin: bool,
pub success_regex: Vec<String>,
pub fail_regex: Vec<String>,
pub shell_prefix: Option<String>,
pub shell_init_cmd: Option<String>,
pub timeout: Option<u64>,
}
impl QemuConfig {
fn replace_strings(&mut self, scope: &VariableScope) -> anyhow::Result<()> {
self.args = self
.args
.iter()
.map(|arg| variables::expand_variables(arg, scope))
.collect::<anyhow::Result<Vec<_>>>()?;
self.success_regex = self
.success_regex
.iter()
.map(|arg| variables::expand_variables(arg, scope))
.collect::<anyhow::Result<Vec<_>>>()?;
self.fail_regex = self
.fail_regex
.iter()
.map(|arg| variables::expand_variables(arg, scope))
.collect::<anyhow::Result<Vec<_>>>()?;
self.shell_prefix = self
.shell_prefix
.as_deref()
.map(|value| variables::expand_variables(value, scope))
.transpose()?;
self.shell_init_cmd = self
.shell_init_cmd
.as_deref()
.map(|value| variables::expand_variables(value, scope))
.transpose()?;
Ok(())
}
fn normalize(&mut self, config_name: &str) -> anyhow::Result<()> {
normalize_shell_init_config(
&mut self.shell_prefix,
&mut self.shell_init_cmd,
config_name,
)
}
fn shell_auto_init(&self) -> Option<ShellAutoInitMatcher> {
ShellAutoInitMatcher::new(self.shell_prefix.clone(), self.shell_init_cmd.clone())
}
fn requires_bin_artifact(&self) -> bool {
self.uefi || self.to_bin
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RunQemuOptions {
pub dtb_dump: bool,
}
#[derive(Clone, Debug)]
pub(crate) struct QemuRunInput {
process_context: ProcessContext,
artifacts: OutputArtifacts,
arch: Option<Architecture>,
debug: bool,
}
impl QemuRunInput {
pub(crate) fn new(
process_context: ProcessContext,
artifacts: OutputArtifacts,
arch: Option<Architecture>,
debug: bool,
) -> Self {
Self {
process_context,
artifacts,
arch,
debug,
}
}
}
pub(crate) fn default_qemu_config(arch: Option<Architecture>) -> QemuConfig {
build_default_qemu_config(arch)
}
pub fn default_config(invocation: &Invocation) -> QemuConfig {
default_qemu_config(invocation.runtime_arch())
}
pub(crate) fn default_qemu_config_for_cargo(
cargo: &Cargo,
runtime_arch: Option<Architecture>,
) -> QemuConfig {
build_default_qemu_config(infer_target_arch(&cargo.target).or(runtime_arch))
}
pub fn default_config_for_cargo(invocation: &Invocation, cargo: &Cargo) -> QemuConfig {
default_qemu_config_for_cargo(cargo, invocation.runtime_arch())
}
pub async fn read_config_from_path(
invocation: &Invocation,
path: &Path,
) -> anyhow::Result<QemuConfig> {
let scope = invocation.variable_scope()?;
read_qemu_config_from_path(&scope, path).await
}
pub async fn read_config_from_path_for_cargo(
invocation: &Invocation,
cargo: &Cargo,
path: &Path,
) -> anyhow::Result<QemuConfig> {
let scope = crate::build::cargo_variable_scope(invocation.project_layout(), cargo)?;
read_qemu_config_from_path(&scope, path).await
}
pub(crate) async fn read_qemu_config_from_path(
variables: &VariableScope,
path: &Path,
) -> anyhow::Result<QemuConfig> {
let config_path = variables::expand_path_variables(path, variables)?;
read_qemu_config_at_path(variables, config_path).await
}
pub(crate) async fn ensure_qemu_config_for_cargo(
layout: &ProjectLayout,
variables: &VariableScope,
cargo: &Cargo,
runtime_arch: Option<Architecture>,
) -> anyhow::Result<QemuConfig> {
let package_dir = metadata::package_manifest_dir(layout, &cargo.package)?;
let arch = infer_target_arch(&cargo.target).or(runtime_arch);
let config_path = resolve_qemu_config_path_in_dir(&package_dir, arch, None)?;
let default_config = default_qemu_config_for_cargo(cargo, runtime_arch);
ensure_qemu_config_at_path(variables, config_path, default_config).await
}
pub async fn ensure_config_for_cargo(
invocation: &Invocation,
cargo: &Cargo,
) -> anyhow::Result<QemuConfig> {
let scope = crate::build::cargo_variable_scope(invocation.project_layout(), cargo)?;
ensure_qemu_config_for_cargo(
invocation.project_layout(),
&scope,
cargo,
invocation.runtime_arch(),
)
.await
}
pub(crate) async fn ensure_qemu_config_in_dir(
variables: &VariableScope,
dir: &Path,
runtime_arch: Option<Architecture>,
) -> anyhow::Result<QemuConfig> {
let dir = variables::expand_path_variables(dir, variables)?;
let config_path = resolve_qemu_config_path_in_dir(&dir, runtime_arch, None)?;
let default_config = default_qemu_config(runtime_arch);
ensure_qemu_config_at_path(variables, config_path, default_config).await
}
pub async fn ensure_config_in_dir(
invocation: &Invocation,
dir: &Path,
) -> anyhow::Result<QemuConfig> {
let scope = invocation.variable_scope()?;
ensure_qemu_config_in_dir(&scope, dir, invocation.runtime_arch()).await
}
pub(crate) fn prepare_qemu_runtime_config(
variables: &VariableScope,
config: &QemuConfig,
) -> anyhow::Result<QemuConfig> {
let mut config = config.clone();
config.replace_strings(variables)?;
config.normalize("QEMU runtime config")?;
Ok(config)
}
pub(crate) async fn run_qemu_with_config(
input: QemuRunInput,
run_args: RunQemuOptions,
config: QemuConfig,
) -> anyhow::Result<()> {
if config.requires_bin_artifact() {
input
.artifacts
.require_bin("QEMU runtime requires a prepared BIN artifact")?;
}
let mut runner = QemuRunner {
input,
config,
dtbdump: run_args.dtb_dump,
success_regex: vec![],
fail_regex: vec![],
};
runner.run().await
}
pub async fn run_qemu(
invocation: &mut Invocation,
config: &QemuConfig,
options: RunQemuOptions,
) -> anyhow::Result<()> {
run_qemu_with_debug(invocation, config, options, invocation.options().debug()).await
}
pub(crate) async fn run_qemu_with_debug(
invocation: &mut Invocation,
config: &QemuConfig,
options: RunQemuOptions,
debug: bool,
) -> anyhow::Result<()> {
let scope = invocation.variable_scope()?;
let config = prepare_qemu_runtime_config(&scope, config)?;
if config.requires_bin_artifact() {
invocation.ensure_runtime_bin()?;
}
let input = QemuRunInput::new(
invocation.process_context()?,
invocation.runtime_artifacts().clone(),
invocation.runtime_arch(),
debug,
);
run_qemu_with_config(input, options, config).await
}
pub(crate) async fn read_qemu_config_at_path(
variables: &VariableScope,
config_path: PathBuf,
) -> anyhow::Result<QemuConfig> {
info!("Using QEMU config file: {}", config_path.display());
let content = fs::read_to_string(&config_path)
.await
.with_context(|| format!("failed to read QEMU config: {}", config_path.display()))?;
let mut config: QemuConfig = toml::from_str(&content)
.with_context(|| format!("failed to parse QEMU config: {}", config_path.display()))?;
config.replace_strings(variables)?;
config.normalize(&format!("QEMU config {}", config_path.display()))?;
Ok(config)
}
pub(crate) async fn ensure_qemu_config_at_path(
variables: &VariableScope,
config_path: PathBuf,
default_config: QemuConfig,
) -> anyhow::Result<QemuConfig> {
info!("Using QEMU config file: {}", config_path.display());
let config_content = match fs::read_to_string(&config_path).await {
Ok(_) => return read_qemu_config_at_path(variables, config_path).await,
Err(e) if e.kind() == io::ErrorKind::NotFound => {
let mut config = default_config;
config.normalize(&format!("QEMU config {}", config_path.display()))?;
fs::write(&config_path, toml::to_string_pretty(&config)?)
.await
.with_path("failed to write file", &config_path)?;
config
}
Err(e) => return Err(e.into()),
};
Ok(config_content)
}
fn build_default_qemu_config(arch: Option<Architecture>) -> QemuConfig {
let mut config = QemuConfig {
to_bin: true,
..Default::default()
};
config.args.push("-nographic".to_string());
if let Some(arch) = arch {
match arch {
Architecture::Aarch64 => {
config.args.push("-cpu".to_string());
config.args.push("cortex-a53".to_string());
}
Architecture::Riscv64 => {
config.args.push("-cpu".to_string());
config.args.push("rv64".to_string());
}
_ => {}
}
}
config
}
pub(crate) fn infer_target_arch(target: &str) -> Option<Architecture> {
let target = target.trim();
if target.is_empty() {
return None;
}
let triple = target.split('-').next().unwrap_or(target);
match triple {
"aarch64" => Some(Architecture::Aarch64),
"arm" | "armv7" | "armv7a" | "armv7r" | "thumbv7em" => Some(Architecture::Arm),
"riscv64" | "riscv64gc" => Some(Architecture::Riscv64),
"x86_64" => Some(Architecture::X86_64),
"i386" | "i586" | "i686" => Some(Architecture::I386),
"loongarch64" => Some(Architecture::LoongArch64),
_ => None,
}
}
struct QemuRunner {
input: QemuRunInput,
config: QemuConfig,
dtbdump: bool,
success_regex: Vec<regex::Regex>,
fail_regex: Vec<regex::Regex>,
}
impl QemuRunner {
async fn run(&mut self) -> anyhow::Result<()> {
self.prepare_regex()?;
let detected_arch = self.input.arch.ok_or_else(|| {
anyhow!("Please specify `arch` in QEMU config or provide a valid ELF file.")
})?;
let arch = format!("{detected_arch:?}").to_lowercase();
let machine = match detected_arch {
Architecture::X86_64 | Architecture::I386 => "q35",
_ => "virt",
}
.to_string();
#[allow(unused_mut)]
let mut qemu_executable = format!("qemu-system-{arch}");
#[cfg(windows)]
{
println!("{}", "Checking for QEMU executable on Windows...".blue());
let msys2 =
PathBuf::from("C:\\msys64\\ucrt64\\bin").join(format!("{qemu_executable}.exe"));
if msys2.exists() {
println!("Using QEMU executable from MSYS2: {}", msys2.display());
qemu_executable = msys2.to_string_lossy().to_string();
}
}
let dtb_dump_path = if self.dtbdump {
Some(
prepare_qemu_dtb_dump(default_qemu_dtb_dump_path())
.await?
.path()
.to_path_buf(),
)
} else {
None
};
let boot_source = if let Some(uefi) = self.prepare_uefi().await? {
match uefi {
UefiBootConfig::Pflash {
code,
vars,
esp_dir,
} => Some(QemuBootSource::uefi_pflash(code, vars, esp_dir)),
}
} else {
self.input
.artifacts
.runtime_image()
.map(|path| QemuBootSource::direct_kernel_loader(path.to_path_buf()))
};
let plan = build_qemu_command_plan(QemuCommandPlanInput {
executable: qemu_executable,
config_args: self.config.args.iter().map(OsString::from).collect(),
default_machine: machine,
dtb_dump_path,
debug: self.input.debug,
boot_source,
});
let mut cmd = plan.render(&self.input.process_context);
cmd.stdin(Stdio::piped());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
cmd.print_cmd();
let mut child = TokioCommand::from(cmd.into_std()).spawn()?;
let started_at = Instant::now();
let stdin = child.stdin.take().context("failed to capture QEMU stdin")?;
let stdout = child
.stdout
.take()
.context("failed to capture QEMU stdout")?;
let stderr = child
.stderr
.take()
.context("failed to capture QEMU stderr")?;
let (inbound_tx, inbound_rx) = mpsc::unbounded_channel::<Vec<u8>>();
let (outbound_tx, mut outbound_rx) = mpsc::unbounded_channel::<Vec<u8>>();
let stderr_capture = Arc::new(Mutex::new(Vec::<u8>::new()));
let stdout_task = tokio::spawn(read_child_stream(stdout, inbound_tx.clone(), None));
let stderr_task = tokio::spawn(read_child_stream(
stderr,
inbound_tx,
Some(stderr_capture.clone()),
));
let write_task = tokio::spawn(async move {
let mut stdin = stdin;
while let Some(bytes) = outbound_rx.recv().await {
if let Err(err) = stdin.write_all(&bytes).await {
if err.kind() != ErrorKind::BrokenPipe {
return Err(err).context("failed to forward stdin to QEMU");
}
break;
}
stdin.flush().await.context("failed to flush QEMU stdin")?;
}
Ok::<(), anyhow::Error>(())
});
let matcher = Arc::new(Mutex::new(ByteStreamMatcher::new(
self.success_regex.clone(),
self.fail_regex.clone(),
)));
let shell_auto_init = Arc::new(Mutex::new(self.config.shell_auto_init()));
let match_result = Arc::new(Mutex::new(None));
let terminal = AsyncTerminal::new(TerminalConfig {
intercept_exit_sequence: false,
timeout: timeout_duration(self.config.timeout),
timeout_label: "QEMU".to_string(),
});
let terminal_result = terminal
.run(inbound_rx, outbound_tx, {
let matcher = matcher.clone();
let shell_auto_init = shell_auto_init.clone();
let match_result = match_result.clone();
move |handle, byte| {
let mut matcher = matcher.lock().unwrap();
if let Some(matched) = matcher.observe_byte(byte) {
print_match_event(&matched);
let mut result = match_result.lock().unwrap();
*result = Some(matched);
handle.stop_after(crate::run::output_matcher::MATCH_DRAIN_DURATION);
}
let mut shell_auto_init = shell_auto_init.lock().unwrap();
if let Some(shell_auto_init) = shell_auto_init.as_mut()
&& let Some(command) = shell_auto_init.observe_byte(byte)
{
handle.send_after(SHELL_INIT_DELAY, command);
}
if matcher.should_stop() {
handle.stop();
}
}
})
.await;
let should_kill = matcher.lock().unwrap().should_stop() || terminal_result.is_err();
if should_kill
&& child
.try_wait()
.context("failed to query QEMU process status")?
.is_none()
&& let Err(err) = child.kill().await
&& err.kind() != ErrorKind::InvalidInput
{
return Err(err.into());
}
let status = child.wait().await?;
let _ = stdout_task.await;
let _ = stderr_task.await;
let _ = write_task.await;
let stderr = stderr_capture.lock().unwrap().clone();
RunnerExecutionSummary::new(
"QEMU",
RunnerExitStatus::process(status),
started_at.elapsed(),
)
.with_terminal_error(terminal_result.err())
.with_stream_match(match_result.lock().unwrap().take())
.with_stderr_log(&stderr)
.into_result()
}
async fn prepare_uefi(&self) -> anyhow::Result<Option<UefiBootConfig>> {
if !self.config.uefi {
return Ok(None);
}
let arch = self
.input
.arch
.ok_or_else(|| anyhow::anyhow!("Cannot determine architecture for OVMF preparation"))?;
let tmp = std::env::temp_dir();
let bios_dir = tmp.join("ostool").join("ovmf");
fs::create_dir_all(&bios_dir)
.await
.with_path("failed to create directory", &bios_dir)?;
println!("Preparing OVMF firmware for architecture: {arch:?}");
let prebuilt = Prebuilt::fetch(Source::LATEST, &bios_dir)
.with_context(|| format!("failed to prepare OVMF cache: {}", bios_dir.display()))?;
let arch = match arch {
Architecture::X86_64 => Arch::X64,
Architecture::Aarch64 => Arch::Aarch64,
Architecture::Riscv64 => Arch::Riscv64,
Architecture::LoongArch64 => Arch::LoongArch64,
Architecture::I386 => Arch::Ia32,
o => return Err(anyhow::anyhow!("OVMF is not supported for {o:?} ",)),
};
let code = prebuilt.get_file(arch, FileType::Code);
let vars_template = prebuilt.get_file(arch, FileType::Vars);
let esp_dir = self.prepare_uefi_esp(arch).await?;
let vars = self.prepare_uefi_vars(&vars_template).await?;
Ok(Some(UefiBootConfig::Pflash {
code,
vars,
esp_dir,
}))
}
async fn prepare_uefi_esp(&self, arch: Arch) -> anyhow::Result<PathBuf> {
let bin_path = self
.input
.artifacts
.require_bin("UEFI boot requires a BIN artifact")?
.to_path_buf();
let stem = bin_path
.file_stem()
.ok_or_else(|| anyhow!("invalid BIN path: {}", bin_path.display()))?;
let artifact_dir = self.uefi_artifact_dir(&bin_path)?;
let esp_dir = artifact_dir.join(format!("{}.esp", stem.to_string_lossy()));
let boot_dir = esp_dir.join("EFI").join("BOOT");
fs::create_dir_all(&boot_dir)
.await
.with_path("failed to create directory", &boot_dir)?;
let boot_path = boot_dir.join(Self::default_uefi_boot_filename(arch));
fs::copy(&bin_path, &boot_path).await.with_context(|| {
format!(
"failed to copy EFI image from {} to {}",
bin_path.display(),
boot_path.display()
)
})?;
Ok(esp_dir)
}
fn uefi_artifact_dir(&self, bin_path: &Path) -> anyhow::Result<PathBuf> {
if let Some(dir) = self.input.artifacts.runtime_artifact_dir() {
return Ok(dir.to_path_buf());
}
let bin_path = bin_path
.canonicalize()
.with_path("failed to canonicalize file", bin_path)?;
bin_path
.parent()
.map(PathBuf::from)
.ok_or_else(|| anyhow!("invalid BIN path: {}", bin_path.display()))
}
async fn prepare_uefi_vars(&self, vars_template: &Path) -> anyhow::Result<PathBuf> {
let bin_path = self
.input
.artifacts
.require_bin("UEFI boot requires a BIN artifact")?
.to_path_buf();
let stem = bin_path
.file_stem()
.ok_or_else(|| anyhow!("invalid BIN path: {}", bin_path.display()))?;
let artifact_dir = self.uefi_artifact_dir(&bin_path)?;
fs::create_dir_all(&artifact_dir)
.await
.with_path("failed to create directory", &artifact_dir)?;
let vars = artifact_dir.join(format!("{}.vars.fd", stem.to_string_lossy()));
fs::copy(vars_template, &vars).await.with_context(|| {
format!(
"failed to copy OVMF vars from {} to {}",
vars_template.display(),
vars.display()
)
})?;
Ok(vars)
}
fn default_uefi_boot_filename(arch: Arch) -> &'static str {
match arch {
Arch::Aarch64 => "BOOTAA64.EFI",
Arch::Ia32 => "BOOTIA32.EFI",
Arch::LoongArch64 => "BOOTLOONGARCH64.EFI",
Arch::Riscv64 => "BOOTRISCV64.EFI",
Arch::X64 => "BOOTX64.EFI",
}
}
fn prepare_regex(&mut self) -> anyhow::Result<()> {
let (success, fail) = compile_regexes(&self.config.success_regex, &self.config.fail_regex)?;
self.success_regex = success;
self.fail_regex = fail;
Ok(())
}
}
pub(crate) fn resolve_qemu_config_path_in_dir(
search_dir: &Path,
arch: Option<Architecture>,
explicit_path: Option<PathBuf>,
) -> anyhow::Result<PathBuf> {
if let Some(path) = explicit_path {
return Ok(path);
}
let arch_str = arch.map(|arch| format!("{arch:?}").to_lowercase());
let candidates: Vec<String> = if let Some(ref arch) = arch_str {
vec![
format!("qemu-{}.toml", arch),
format!(".qemu-{}.toml", arch),
"qemu.toml".to_string(),
".qemu.toml".to_string(),
]
} else {
vec!["qemu.toml".to_string(), ".qemu.toml".to_string()]
};
for filename in &candidates {
let path = search_dir.join(filename);
if path.exists() {
return Ok(path);
}
}
let default_filename = if let Some(ref arch) = arch_str {
format!(".qemu-{arch}.toml")
} else {
".qemu.toml".to_string()
};
Ok(search_dir.join(default_filename))
}
async fn read_child_stream<R>(
mut reader: R,
tx: mpsc::UnboundedSender<Vec<u8>>,
capture: Option<Arc<Mutex<Vec<u8>>>>,
) -> anyhow::Result<()>
where
R: AsyncRead + Unpin,
{
let mut buffer = [0u8; 1024];
loop {
let read = reader.read(&mut buffer).await?;
if read == 0 {
break;
}
if let Some(capture) = capture.as_ref() {
capture.lock().unwrap().extend_from_slice(&buffer[..read]);
}
if tx.send(buffer[..read].to_vec()).is_err() {
break;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
QemuConfig, QemuRunInput, QemuRunner, RunQemuOptions, build_default_qemu_config,
default_qemu_config_for_cargo, ensure_config_for_cargo, ensure_qemu_config_at_path,
infer_target_arch, read_config_from_path, read_qemu_config_at_path,
resolve_qemu_config_path_in_dir, run_qemu_with_config, timeout_duration,
};
use object::Architecture;
use std::{
path::{Path, PathBuf},
time::Duration,
};
use tempfile::TempDir;
use crate::{
artifact::{
object_tools::ObjectTools,
runtime::{RuntimeArtifactOptions, prepare_runtime_artifacts},
state::OutputArtifacts,
},
build::{
config::{BuildConfig, BuildSystem, Cargo},
config_loader,
},
invocation::{Invocation, InvocationOptions},
run::{
output_matcher::{ByteStreamMatcher, StreamMatchKind},
shell_init::ShellAutoInitMatcher,
},
};
use std::collections::HashMap;
fn write_single_crate_manifest(dir: &std::path::Path) {
std::fs::write(
dir.join("Cargo.toml"),
"[package]\nname = \"sample\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[workspace]\n",
)
.unwrap();
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src/lib.rs"), "").unwrap();
}
fn make_invocation(dir: &std::path::Path) -> Invocation {
Invocation::new(InvocationOptions::new(
Some(dir.to_path_buf()),
None,
None,
false,
))
.unwrap()
}
fn qemu_input(invocation: &Invocation) -> QemuRunInput {
QemuRunInput::new(
invocation.process_context().unwrap(),
invocation.runtime_artifacts().clone(),
invocation.runtime_arch(),
invocation.options().debug(),
)
}
#[test]
fn default_qemu_config_keeps_existing_defaults_without_overrides() {
let config = build_default_qemu_config(Some(Architecture::Aarch64));
assert!(config.to_bin);
assert_eq!(config.args, vec!["-nographic", "-cpu", "cortex-a53"]);
assert!(config.success_regex.is_empty());
assert!(config.fail_regex.is_empty());
assert_eq!(config.timeout, None);
}
#[test]
fn default_qemu_config_for_other_arch_only_adds_generic_defaults() {
let config = build_default_qemu_config(Some(Architecture::X86_64));
assert!(config.to_bin);
assert_eq!(config.args, vec!["-nographic"]);
assert_eq!(config.timeout, None);
}
#[test]
fn infer_target_arch_maps_known_target_triples() {
assert_eq!(
infer_target_arch("aarch64-unknown-none"),
Some(Architecture::Aarch64)
);
assert_eq!(
infer_target_arch("riscv64gc-unknown-none-elf"),
Some(Architecture::Riscv64)
);
assert_eq!(
infer_target_arch("x86_64-unknown-none"),
Some(Architecture::X86_64)
);
assert_eq!(infer_target_arch(""), None);
}
#[tokio::test]
async fn load_existing_qemu_config_preserves_file_contents() {
let tmp = TempDir::new().unwrap();
write_single_crate_manifest(tmp.path());
let config_path = tmp.path().join(".qemu.toml");
std::fs::write(
&config_path,
r#"
args = ["-nographic", "-machine", "virt"]
uefi = false
to_bin = false
success_regex = ["PASS"]
fail_regex = ["FAIL"]
shell_prefix = "login:"
shell_init_cmd = "root"
"#,
)
.unwrap();
let invocation = make_invocation(tmp.path());
let scope = invocation.variable_scope().unwrap();
let config = read_qemu_config_at_path(&scope, config_path).await.unwrap();
assert!(!config.to_bin);
assert_eq!(config.success_regex, vec!["PASS"]);
assert_eq!(config.fail_regex, vec!["FAIL"]);
assert_eq!(config.shell_prefix.as_deref(), Some("login:"));
assert_eq!(config.shell_init_cmd.as_deref(), Some("root"));
assert_eq!(config.args, vec!["-nographic", "-machine", "virt"]);
}
#[tokio::test]
async fn load_missing_qemu_config_uses_default_template() {
let tmp = TempDir::new().unwrap();
write_single_crate_manifest(tmp.path());
let config_path = tmp.path().join(".qemu.toml");
let invocation = make_invocation(tmp.path());
let config = ensure_qemu_config_at_path(
&invocation.variable_scope().unwrap(),
config_path.clone(),
build_default_qemu_config(Some(Architecture::Aarch64)),
)
.await
.unwrap();
assert!(config.to_bin);
assert_eq!(config.args, vec!["-nographic", "-cpu", "cortex-a53"]);
assert!(config_path.exists());
}
#[tokio::test]
async fn load_qemu_config_for_cargo_prefers_package_dir() {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join("Cargo.toml"),
"[workspace]\nmembers = [\"app\", \"kernel\"]\nresolver = \"3\"\n",
)
.unwrap();
let app_dir = tmp.path().join("app");
std::fs::create_dir_all(app_dir.join("src")).unwrap();
std::fs::write(
app_dir.join("Cargo.toml"),
"[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2024\"\n",
)
.unwrap();
std::fs::write(app_dir.join("src/main.rs"), "fn main() {}\n").unwrap();
let kernel_dir = tmp.path().join("kernel");
std::fs::create_dir_all(kernel_dir.join("src")).unwrap();
std::fs::write(
kernel_dir.join("Cargo.toml"),
"[package]\nname = \"kernel\"\nversion = \"0.1.0\"\nedition = \"2024\"\n",
)
.unwrap();
std::fs::write(kernel_dir.join("src/main.rs"), "fn main() {}\n").unwrap();
std::fs::write(
kernel_dir.join(".qemu-aarch64.toml"),
r#"
args = ["-custom"]
uefi = false
to_bin = true
success_regex = []
fail_regex = []
"#,
)
.unwrap();
let invocation =
Invocation::new(InvocationOptions::new(Some(app_dir), None, None, false)).unwrap();
let config = ensure_config_for_cargo(
&invocation,
&Cargo {
env: HashMap::new(),
target: "aarch64-unknown-none".into(),
package: "kernel".into(),
bin: None,
features: vec![],
log: None,
extra_config: None,
profile: None,
disable_someboot_build_config: false,
args: vec![],
pre_build_cmds: vec![],
post_build_cmds: vec![],
to_bin: false,
},
)
.await
.unwrap();
assert_eq!(config.args, vec!["-custom"]);
}
#[tokio::test]
async fn run_qemu_with_config_rejects_missing_required_bin_artifact() {
let tmp = TempDir::new().unwrap();
write_single_crate_manifest(tmp.path());
let source = std::env::current_exe().unwrap();
let copied = tmp.path().join("sample-elf");
std::fs::copy(&source, &copied).unwrap();
let mut invocation = make_invocation(tmp.path());
let prepared = prepare_runtime_artifacts(
&invocation.process_context().unwrap(),
RuntimeArtifactOptions {
elf_path: copied,
to_bin: false,
bin_dir: None,
debug: false,
cargo_artifact_dir: None,
strip_elf: false,
objcopy_program: ObjectTools.objcopy(),
},
)
.unwrap();
invocation.apply_prepared_runtime_artifacts(prepared);
let input = qemu_input(&invocation);
assert!(input.artifacts.elf().is_some());
assert!(input.artifacts.bin().is_none());
let err = run_qemu_with_config(
input,
RunQemuOptions::default(),
QemuConfig {
to_bin: true,
..Default::default()
},
)
.await
.unwrap_err();
assert!(
err.to_string()
.contains("QEMU runtime requires a prepared BIN artifact")
);
}
#[test]
fn qemu_config_marks_bin_required_for_uefi_and_legacy_to_bin() {
assert!(
QemuConfig {
uefi: true,
to_bin: false,
..Default::default()
}
.requires_bin_artifact()
);
assert!(
QemuConfig {
uefi: false,
to_bin: true,
..Default::default()
}
.requires_bin_artifact()
);
assert!(
!QemuConfig {
uefi: false,
to_bin: false,
..Default::default()
}
.requires_bin_artifact()
);
}
#[test]
fn default_qemu_config_for_cargo_uses_target_arch() {
let config = default_qemu_config_for_cargo(
&Cargo {
env: HashMap::new(),
target: "riscv64gc-unknown-none-elf".into(),
package: "sample".into(),
bin: None,
features: vec![],
log: None,
extra_config: None,
profile: None,
disable_someboot_build_config: false,
args: vec![],
pre_build_cmds: vec![],
post_build_cmds: vec![],
to_bin: false,
},
None,
);
assert_eq!(config.args, vec!["-nographic", "-cpu", "rv64"]);
}
#[test]
fn qemu_timeout_zero_disables_timeout() {
assert_eq!(timeout_duration(None), None);
assert_eq!(timeout_duration(Some(0)), None);
assert_eq!(timeout_duration(Some(3)), Some(Duration::from_secs(3)));
}
#[test]
fn qemu_config_parses_timeout_from_toml() {
let config: QemuConfig = toml::from_str(
r#"
args = ["-nographic"]
uefi = false
to_bin = true
success_regex = []
fail_regex = []
timeout = 0
"#,
)
.unwrap();
assert_eq!(config.timeout, Some(0));
}
#[test]
fn qemu_config_defaults_to_bin_to_false_when_field_is_absent() {
let config: QemuConfig = toml::from_str(
r#"
args = ["-nographic"]
uefi = false
success_regex = []
fail_regex = []
"#,
)
.unwrap();
assert!(!config.to_bin);
}
#[test]
fn qemu_config_normalize_rejects_shell_init_without_prefix() {
let mut config = QemuConfig {
shell_init_cmd: Some("root".into()),
..Default::default()
};
let err = config.normalize("test config").unwrap_err();
assert!(err.to_string().contains("shell_prefix"));
}
#[test]
fn qemu_config_normalize_trims_shell_fields() {
let mut config = QemuConfig {
shell_prefix: Some(" login: ".into()),
shell_init_cmd: Some(" root ".into()),
..Default::default()
};
config.normalize("test config").unwrap();
assert_eq!(config.shell_prefix.as_deref(), Some("login:"));
assert_eq!(config.shell_init_cmd.as_deref(), Some("root"));
}
#[test]
fn qemu_shell_auto_init_can_coexist_with_success_matcher() {
let mut matcher = ByteStreamMatcher::new(
vec![regex::Regex::new("ready").unwrap()],
vec![regex::Regex::new("__never_fail__").unwrap()],
);
let mut shell_init =
ShellAutoInitMatcher::new(Some("login:".to_string()), Some("root".to_string()))
.unwrap();
let mut sent = None;
for byte in b"login: system ready\n" {
if sent.is_none() {
sent = shell_init.observe_byte(*byte);
} else {
let _ = shell_init.observe_byte(*byte);
}
let _ = matcher.observe_byte(*byte);
}
let matched = matcher.matched().unwrap();
assert_eq!(matched.kind, StreamMatchKind::Success);
assert_eq!(sent.as_deref(), Some(&b"root\n"[..]));
}
#[test]
fn uefi_artifact_dir_prefers_runtime_artifact_dir() {
let runtime_dir = PathBuf::from("/tmp/ostool-runtime");
let tmp = TempDir::new().unwrap();
write_single_crate_manifest(tmp.path());
let invocation = make_invocation(tmp.path());
let mut artifacts = OutputArtifacts::default();
artifacts.set_runtime_artifact_dir(runtime_dir.clone());
let input = QemuRunInput::new(
invocation.process_context().unwrap(),
artifacts,
invocation.runtime_arch(),
invocation.options().debug(),
);
let runner = QemuRunner {
input,
config: QemuConfig::default(),
dtbdump: false,
success_regex: vec![],
fail_regex: vec![],
};
let resolved = runner
.uefi_artifact_dir(PathBuf::from("/tmp/ignored/kernel.bin").as_path())
.unwrap();
assert_eq!(resolved, runtime_dir);
}
#[test]
fn qemu_config_explicit_path_wins() {
let tmp = TempDir::new().unwrap();
write_single_crate_manifest(tmp.path());
let invocation = make_invocation(tmp.path());
let explicit = tmp.path().join("custom.qemu.toml");
let result = resolve_qemu_config_path_in_dir(
invocation.workspace_dir(),
invocation.runtime_arch(),
Some(explicit.clone()),
)
.unwrap();
assert_eq!(result, explicit);
}
#[test]
fn qemu_config_workspace_path_used() {
let tmp = TempDir::new().unwrap();
write_single_crate_manifest(tmp.path());
std::fs::write(tmp.path().join("qemu-aarch64.toml"), "").unwrap();
let invocation = make_invocation(tmp.path());
let result = resolve_qemu_config_path_in_dir(
invocation.workspace_dir(),
Some(Architecture::Aarch64),
None,
)
.unwrap();
assert_eq!(result, tmp.path().join("qemu-aarch64.toml"));
}
#[test]
fn qemu_config_filename_priority() {
let tmp = TempDir::new().unwrap();
write_single_crate_manifest(tmp.path());
let manifest = tmp.path().to_path_buf();
let invocation = make_invocation(tmp.path());
std::fs::write(manifest.join("qemu.toml"), "").unwrap();
let result = resolve_qemu_config_path_in_dir(
invocation.workspace_dir(),
Some(Architecture::Aarch64),
None,
)
.unwrap();
assert_eq!(result, manifest.join("qemu.toml"));
std::fs::write(manifest.join("qemu-aarch64.toml"), "").unwrap();
let result = resolve_qemu_config_path_in_dir(
invocation.workspace_dir(),
Some(Architecture::Aarch64),
None,
)
.unwrap();
assert_eq!(result, manifest.join("qemu-aarch64.toml"));
}
#[test]
fn qemu_config_replaces_string_fields() {
let tmp = TempDir::new().unwrap();
write_single_crate_manifest(tmp.path());
let mut invocation = make_invocation(tmp.path());
crate::build::activate_build_config(
&mut invocation,
&BuildConfig {
system: BuildSystem::Cargo(Cargo {
env: HashMap::new(),
target: "aarch64-unknown-none".into(),
package: "sample".into(),
bin: None,
features: vec![],
log: None,
extra_config: None,
profile: None,
disable_someboot_build_config: false,
args: vec![],
pre_build_cmds: vec![],
post_build_cmds: vec![],
to_bin: false,
}),
},
None,
)
.unwrap();
unsafe {
std::env::set_var("OSTOOL_QEMU_TEST_ENV", "env-ok");
}
let mut config = QemuConfig {
args: vec!["${workspace}".into(), "${package}".into()],
success_regex: vec!["${env:OSTOOL_QEMU_TEST_ENV}".into()],
fail_regex: vec!["${workspaceFolder}".into()],
shell_prefix: Some("${workspace}".into()),
shell_init_cmd: Some("${package}".into()),
..Default::default()
};
config
.replace_strings(&invocation.variable_scope().unwrap())
.unwrap();
let expected = tmp.path().display().to_string();
assert_eq!(config.args, vec![expected.clone(), expected.clone()]);
assert_eq!(config.success_regex, vec!["env-ok"]);
assert_eq!(config.fail_regex, vec![expected.clone()]);
assert_eq!(config.shell_prefix.as_deref(), Some(expected.as_str()));
assert_eq!(config.shell_init_cmd.as_deref(), Some(expected.as_str()));
}
#[tokio::test]
async fn read_qemu_config_from_variable_path_expands_workspace() {
let tmp = TempDir::new().unwrap();
write_single_crate_manifest(tmp.path());
std::fs::write(
tmp.path().join("qemu.toml"),
r#"
args = ["-nographic"]
uefi = false
to_bin = false
success_regex = []
fail_regex = []
"#,
)
.unwrap();
let invocation = make_invocation(tmp.path());
let config = read_config_from_path(&invocation, Path::new("${workspace}/qemu.toml"))
.await
.unwrap();
assert_eq!(config.args, vec!["-nographic"]);
}
#[test]
fn qemu_config_default_path_with_search_dir() {
let tmp = TempDir::new().unwrap();
write_single_crate_manifest(tmp.path());
let invocation = make_invocation(tmp.path());
let result = resolve_qemu_config_path_in_dir(
invocation.workspace_dir(),
invocation.runtime_arch(),
None,
)
.unwrap();
assert_eq!(result, tmp.path().join(".qemu.toml"));
}
#[test]
fn qemu_config_default_path_with_arch() {
let tmp = TempDir::new().unwrap();
write_single_crate_manifest(tmp.path());
let invocation = make_invocation(tmp.path());
let result = resolve_qemu_config_path_in_dir(
invocation.workspace_dir(),
Some(Architecture::Aarch64),
None,
)
.unwrap();
assert_eq!(result, tmp.path().join(".qemu-aarch64.toml"));
}
#[test]
fn qemu_config_without_arch() {
let tmp = TempDir::new().unwrap();
write_single_crate_manifest(tmp.path());
std::fs::write(tmp.path().join("qemu-aarch64.toml"), "").unwrap();
std::fs::write(tmp.path().join("qemu.toml"), "").unwrap();
let invocation = make_invocation(tmp.path());
let result = resolve_qemu_config_path_in_dir(
invocation.workspace_dir(),
invocation.runtime_arch(),
None,
)
.unwrap();
assert_eq!(result, tmp.path().join("qemu.toml"));
}
#[test]
fn qemu_config_search_dir_prefers_arch_specific_files() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("qemu-aarch64.toml"), "").unwrap();
std::fs::write(tmp.path().join("qemu.toml"), "").unwrap();
let result =
resolve_qemu_config_path_in_dir(tmp.path(), Some(Architecture::Aarch64), None).unwrap();
assert_eq!(result, tmp.path().join("qemu-aarch64.toml"));
}
#[test]
fn qemu_config_search_dir_uses_hidden_generic_before_hidden_default_creation() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join(".qemu.toml"), "").unwrap();
let result =
resolve_qemu_config_path_in_dir(tmp.path(), Some(Architecture::Aarch64), None).unwrap();
assert_eq!(result, tmp.path().join(".qemu.toml"));
}
#[test]
fn qemu_config_search_dir_defaults_to_arch_specific_hidden_file() {
let tmp = TempDir::new().unwrap();
let result =
resolve_qemu_config_path_in_dir(tmp.path(), Some(Architecture::Aarch64), None).unwrap();
assert_eq!(result, tmp.path().join(".qemu-aarch64.toml"));
}
#[test]
fn qemu_config_search_dir_defaults_without_arch() {
let tmp = TempDir::new().unwrap();
let result = resolve_qemu_config_path_in_dir(tmp.path(), None, None).unwrap();
assert_eq!(result, tmp.path().join(".qemu.toml"));
}
#[test]
fn build_config_explicit_path_wins() {
let tmp = TempDir::new().unwrap();
let explicit = tmp.path().join("custom.build.toml");
let result = config_loader::resolve_build_config_path(tmp.path(), Some(explicit.clone()));
assert_eq!(result, explicit);
}
#[test]
fn build_config_defaults_to_workspace_root() {
let tmp = TempDir::new().unwrap();
let result = config_loader::resolve_build_config_path(tmp.path(), None);
assert_eq!(result, tmp.path().join(".build.toml"));
}
}