feat: add --kernel flag for QEMU system emulation mode

Add --kernel <PATH> option to boot extracted rootfs in a QEMU virtual
machine instead of namespace/chroot mode. The rootfs is converted to an
ext4 disk image using mke2fs and booted with the provided kernel.
This commit is contained in:
2026-06-16 19:16:34 +02:00
parent c163f89cb2
commit 4f44af4449
7 changed files with 526 additions and 9 deletions
+8
View File
@@ -33,6 +33,14 @@ pub struct Args {
#[arg(short = 'v', long)]
pub verbose: bool,
/// Boot with QEMU system emulation using specified kernel (extracts rootfs as disk image)
#[arg(long, value_name = "KERNEL_PATH")]
pub kernel: Option<PathBuf>,
/// Memory size for QEMU VM (only used with --kernel, e.g., 512M, 2G)
#[arg(short = 'm', long, default_value = "2G", value_name = "SIZE")]
pub memory: String,
/// Command to run inside the chroot (default: interactive shell)
#[arg(
trailing_var_arg = true,
+48 -9
View File
@@ -7,6 +7,7 @@ mod extract;
mod mount;
mod namespace;
mod qemu;
mod qemu_vm;
mod verbose;
/// Print to stderr only when --verbose / -v is active.
@@ -113,11 +114,56 @@ fn main() -> Result<()> {
veprintln!("Using cached tarball: {}", cache_path.display());
}
// Check QEMU if foreign architecture
if arch != host_arch {
// Check QEMU if foreign architecture (for namespace mode)
// For VM mode, we don't need binfmt_misc since we're using system emulation
if args.kernel.is_none() && arch != host_arch {
qemu::check_binfmt(&arch)?;
}
// Create temp directory for extraction
let temp_dir = tempfile::tempdir()?;
let rootfs = temp_dir.path().to_path_buf();
veprintln!("Extracting to: {}", rootfs.display());
extract_tarball(&cache_path, &rootfs)?;
// Branch based on --kernel flag
if let Some(kernel_path) = &args.kernel {
// QEMU system mode
veprintln!("QEMU mode: booting with kernel {}", kernel_path.display());
let command = if args.command.is_empty() {
None
} else {
Some(args.command.clone())
};
let result = qemu_vm::launch_qemu(qemu_vm::QemuConfig {
kernel_path: kernel_path.clone(),
rootfs_path: rootfs,
memory: args.memory.clone(),
arch: arch.clone(),
command,
});
// Cleanup happens automatically via tempfile
if result.is_ok() {
veprintln!("Cleanup complete.");
}
result
} else {
// Namespace/chroot mode
namespace_mode(args, rootfs, config)
}
}
/// Run in namespace/chroot mode
fn namespace_mode(
args: Args,
rootfs: std::path::PathBuf,
config: Config,
) -> Result<()> {
// Check user namespace availability
namespace::check_user_namespace()?;
@@ -140,13 +186,6 @@ fn main() -> Result<()> {
}
let bind_rw_paths: Vec<std::path::PathBuf> = args.bind_rw.clone();
// Create temp directory for extraction
let temp_dir = tempfile::tempdir()?;
let rootfs = temp_dir.path().to_path_buf();
veprintln!("Extracting to: {}", rootfs.display());
extract_tarball(&cache_path, &rootfs)?;
// Prepare data for the closure
let bind_paths_clone = bind_paths.clone();
let bind_rw_paths_clone = bind_rw_paths.clone();
+345
View File
@@ -0,0 +1,345 @@
use crate::veprintln;
use anyhow::{anyhow, Context, Result};
use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Stdio};
/// QEMU system emulation configuration
pub struct QemuConfig {
/// Path to the kernel image (vmlinuz)
pub kernel_path: PathBuf,
/// Path to the rootfs directory
pub rootfs_path: PathBuf,
/// Memory size for VM (e.g., "2G", "512M")
pub memory: String,
/// Target architecture
pub arch: String,
/// Optional command to run instead of default init
pub command: Option<Vec<String>>,
}
/// Launch QEMU with the given configuration
pub fn launch_qemu(config: QemuConfig) -> Result<()> {
// Check that kernel exists
if !config.kernel_path.exists() {
return Err(anyhow!(
"Kernel not found: {}",
config.kernel_path.display()
));
}
// Check that rootfs exists
if !config.rootfs_path.exists() {
return Err(anyhow!(
"Rootfs not found: {}",
config.rootfs_path.display()
));
}
// Create a disk image from the rootfs
let disk_image = create_disk_image(&config.rootfs_path)?;
// Get QEMU binary for architecture
let qemu_bin = qemu_binary_for_arch(&config.arch);
// Check QEMU exists
which::which(&qemu_bin).context(format!(
"QEMU system emulator '{}' not found. Install it with:\n\
Ubuntu/Debian: sudo apt install qemu-system-{}\n\
Arch: sudo pacman -S qemu-system-{}\n\
Alpine: sudo apk add qemu-system-{}",
qemu_bin, get_arch_package_suffix(&config.arch), get_arch_package_suffix(&config.arch), get_arch_package_suffix(&config.arch)
))?;
// Build kernel command line
// Container rootfs images don't have /sbin/init - they expect a command as PID 1
// We use init=/bin/sh as default, and if a command is specified, we pass it to sh
let kernel_append = if let Some(ref cmd) = config.command {
let cmd_str = cmd.join(" ");
format!(
"root=/dev/vda rw console=ttyS0 init=/bin/sh -- -c \"{}\"",
cmd_str
)
} else {
// Default to interactive shell
"root=/dev/vda rw console=ttyS0 init=/bin/sh".to_string()
};
veprintln!("Launching QEMU: {}", qemu_bin);
veprintln!(" Kernel: {}", config.kernel_path.display());
veprintln!(" Disk image: {}", disk_image.display());
veprintln!(" Memory: {}", config.memory);
veprintln!(" Kernel append: {}", kernel_append);
// Build QEMU arguments
// -display none suppresses VGA/BIOS output
// -serial mon:stdio connects serial console to terminal with QEMU monitor muxed
let args = vec![
"-kernel".to_string(),
config.kernel_path.to_string_lossy().to_string(),
"-append".to_string(),
kernel_append,
"-m".to_string(),
config.memory.clone(),
"-display".to_string(),
"none".to_string(),
"-serial".to_string(),
"mon:stdio".to_string(),
"-drive".to_string(),
format!(
"file={},format=raw,if=virtio",
disk_image.to_string_lossy()
),
"-netdev".to_string(),
"user,id=net0".to_string(),
"-device".to_string(),
"virtio-net-pci,netdev=net0".to_string(),
];
// Execute QEMU
let status = Command::new(&qemu_bin)
.args(&args)
.status()
.context("Failed to execute QEMU")?;
// Cleanup disk image
if let Err(e) = std::fs::remove_file(&disk_image) {
veprintln!("Warning: failed to cleanup disk image: {}", e);
}
if !status.success() {
return Err(anyhow!(
"QEMU exited with non-zero status: {}",
status.code().unwrap_or(-1)
));
}
Ok(())
}
/// Get QEMU system binary name for architecture
fn qemu_binary_for_arch(arch: &str) -> String {
match arch {
"amd64" | "x86_64" => "qemu-system-x86_64".to_string(),
"arm64" | "aarch64" => "qemu-system-aarch64".to_string(),
"armhf" | "armv7" => "qemu-system-arm".to_string(),
"riscv64" => "qemu-system-riscv64".to_string(),
"ppc64el" | "ppc64le" => "qemu-system-ppc64".to_string(),
"s390x" => "qemu-system-s390x".to_string(),
other => format!("qemu-system-{}", other),
}
}
/// Get architecture suffix for package names
fn get_arch_package_suffix(arch: &str) -> &str {
match arch {
"amd64" | "x86_64" => "x86",
"arm64" | "aarch64" => "aarch64",
"armhf" | "armv7" => "arm",
"riscv64" => "riscv64",
"ppc64el" | "ppc64le" => "ppc",
"s390x" => "s390x",
other => other,
}
}
/// Create a raw disk image from a directory
fn create_disk_image(rootfs: &PathBuf) -> Result<PathBuf> {
veprintln!("Creating disk image from rootfs...");
// Create a temporary file for the disk image
let disk_image = rootfs.parent().unwrap().join("rootfs.img");
// Use mke2fs to create an ext4 filesystem image
// First, calculate size needed (du -sb)
let du_output = Command::new("du")
.arg("-sb")
.arg(rootfs)
.output()
.context("Failed to calculate rootfs size")?;
let size_str = String::from_utf8_lossy(&du_output.stdout);
let size: u64 = size_str
.split_whitespace()
.next()
.context("Failed to parse du output")?
.parse()
.context("Failed to parse size")?;
// Add 50% overhead for filesystem metadata, journal, and some free space
// ext4 with journal can have significant overhead
let image_size = size + (size / 2);
// Minimum 64MB for small rootfs to ensure enough space for metadata
let image_size = image_size.max(64 * 1024 * 1024);
veprintln!("Rootfs size: {} bytes, image size: {} bytes", size, image_size);
// Create the image file
let image_file = std::fs::File::create(&disk_image)
.context("Failed to create disk image file")?;
// Pre-allocate the file
image_file
.set_len(image_size)
.context("Failed to allocate disk image")?;
drop(image_file);
// Try to use mke2fs to create an ext4 image with the directory contents
// This is the most efficient way on Linux
veprintln!("Running: mke2fs -t ext4 -d {} {}", rootfs.display(), disk_image.display());
let mke2fs_result = Command::new("mke2fs")
.arg("-t")
.arg("ext4")
.arg("-d")
.arg(rootfs)
.arg(&disk_image)
.output();
match mke2fs_result {
Ok(output) => {
if output.status.success() {
veprintln!("Disk image created successfully with mke2fs");
// Verify the image has content by checking if we can list files
let verify = Command::new("debugfs")
.arg("-R")
.arg("ls -l /")
.arg(&disk_image)
.output();
if let Ok(v) = verify {
veprintln!("Root directory contents:\n{}", String::from_utf8_lossy(&v.stdout));
}
return Ok(disk_image);
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
veprintln!("mke2fs failed!");
veprintln!(" stdout: {}", stdout);
veprintln!(" stderr: {}", stderr);
// Don't continue if mke2fs exists but failed - it's the only reliable method
return Err(anyhow!(
"mke2fs -d failed to create disk image.\n\
stdout: {}\n\
stderr: {}",
stdout, stderr
));
}
}
Err(e) => {
veprintln!("mke2fs not available: {}", e);
}
}
// Fallback: create a simple ext4 image and copy files
// Try mkfs.ext4
let mkfs_result = Command::new("mkfs.ext4")
.arg("-F")
.arg(&disk_image)
.output();
match mkfs_result {
Ok(output) => {
if !output.status.success() {
return Err(anyhow!(
"mkfs.ext4 failed: {}",
String::from_utf8_lossy(&output.stderr)
));
}
}
Err(e) => {
return Err(anyhow!(
"Neither mke2fs nor mkfs.ext4 available. Install e2fsprogs:\n\
Ubuntu/Debian: sudo apt install e2fsprogs\n\
Arch: sudo pacman -S e2fsprogs\n\
Alpine: sudo apk add e2fsprogs\n\
Error: {}",
e
));
}
}
// Mount the image and copy files
veprintln!("Mounting disk image and copying files...");
// Use debugfs to copy files (doesn't require root/mount)
let debugfs_result = copy_with_debugfs(rootfs, &disk_image)?;
if debugfs_result {
veprintln!("Disk image created successfully");
return Ok(disk_image);
}
// If debugfs failed, we need to try mounting (requires root or fuse)
// This is a last resort
Err(anyhow!(
"Could not create disk image. Please ensure e2fsprogs is installed with mke2fs support.\n\
The mke2fs -d option is required for non-root disk image creation."
))
}
/// Copy files to the disk image using debugfs
fn copy_with_debugfs(rootfs: &PathBuf, disk_image: &PathBuf) -> Result<bool> {
// Use debugfs to write files - this doesn't require mounting
let mut debugfs = match Command::new("debugfs")
.arg("-w")
.arg(disk_image)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(child) => child,
Err(_) => return Ok(false),
};
let stdin = debugfs.stdin.as_mut().context("Failed to open debugfs stdin")?;
// Write files recursively
fn write_directory(
dir: &PathBuf,
prefix: &str,
stdin: &mut std::process::ChildStdin,
) -> Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
let target = if prefix.is_empty() {
format!("/{}", name)
} else {
format!("{}/{}", prefix, name)
};
if path.is_dir() {
// Create directory
writeln!(stdin, "mkdir {}", target)?;
write_directory(&path, &target, stdin)?;
} else {
// Write file
writeln!(stdin, "write {} {}", path.display(), target)?;
}
}
Ok(())
}
if let Err(e) = write_directory(rootfs, "", stdin) {
veprintln!("debugfs write failed: {}", e);
let _ = debugfs.kill();
return Ok(false);
}
// stdin is implicitly dropped here when it goes out of scope
let output = debugfs
.wait_with_output()
.context("Failed to wait for debugfs")?;
if !output.status.success() {
veprintln!(
"debugfs failed: {}",
String::from_utf8_lossy(&output.stderr)
);
return Ok(false);
}
Ok(true)
}