7a37f99030
- Move detect_shell() to new src/utils.rs for reuse - Use detected shell in QEMU VM mode (bash when available) - Update chroot.rs and qemu_vm.rs to use shared function
411 lines
15 KiB
Rust
411 lines
15 KiB
Rust
use crate::veprintln;
|
|
use anyhow::{anyhow, Context, Result};
|
|
use cpio::{newc, NewcBuilder};
|
|
use indicatif::{ProgressBar, ProgressStyle};
|
|
use std::io::Write;
|
|
use std::os::unix::fs::MetadataExt;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
|
|
/// 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 gzipped cpio initramfs from the rootfs
|
|
let initramfs = create_initramfs(&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)
|
|
))?;
|
|
|
|
// Detect the best available shell in the rootfs
|
|
let shell = crate::utils::detect_shell(&config.rootfs_path);
|
|
|
|
// Generate a unique hostname like "ecr-vm-a1b2c3"
|
|
let hostname_suffix = format!("{:x}", (std::process::id() as u64)
|
|
.wrapping_mul(std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_nanos() as u64) % 0x1000000); // 6 hex chars
|
|
let hostname = format!("ecr-vm-{}", hostname_suffix);
|
|
|
|
// Build kernel command line
|
|
// For initramfs boot, use rdinit= instead of init=
|
|
// No root= needed as initramfs becomes the rootfs
|
|
// 'quiet' suppresses kernel log messages for a cleaner console
|
|
// Use setsid with redirected stdio to get proper job control
|
|
// /dev/ttyS0 is created in the initramfs for serial console
|
|
// Set hostname before spawning shell
|
|
let kernel_append = if let Some(ref cmd) = config.command {
|
|
let cmd_str = cmd.join(" ");
|
|
format!(
|
|
"console=ttyS0 quiet rdinit=/bin/sh -- -c \"echo {} >/etc/hostname; hostname {}; setsid sh -c 'exec {} </dev/ttyS0 >/dev/ttyS0 2>&1 -c {}'\"",
|
|
hostname, hostname, shell, cmd_str
|
|
)
|
|
} else {
|
|
// Default to interactive shell with proper job control
|
|
format!(
|
|
"console=ttyS0 quiet rdinit=/bin/sh -- -c \"echo {} >/etc/hostname; hostname {}; setsid sh -c 'exec {} </dev/ttyS0 >/dev/ttyS0 2>&1'\"",
|
|
hostname, hostname, shell
|
|
)
|
|
};
|
|
|
|
veprintln!("Launching QEMU: {}", qemu_bin);
|
|
veprintln!(" Kernel: {}", config.kernel_path.display());
|
|
veprintln!(" Initramfs: {}", initramfs.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(),
|
|
"-initrd".to_string(),
|
|
initramfs.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(),
|
|
"-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 initramfs
|
|
if let Err(e) = std::fs::remove_file(&initramfs) {
|
|
veprintln!("Warning: failed to cleanup initramfs: {}", 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 gzipped cpio initramfs from a directory
|
|
fn create_initramfs(rootfs: &PathBuf) -> Result<PathBuf> {
|
|
// Create a temporary file for the initramfs
|
|
let initramfs_path = rootfs.parent().unwrap().join("initramfs.cpio.gz");
|
|
|
|
// Create progress bar
|
|
let pb = ProgressBar::new_spinner();
|
|
pb.set_style(ProgressStyle::default_spinner()
|
|
.template("{spinner:.green} {msg} ({pos} files)")
|
|
.unwrap());
|
|
pb.set_message("Scanning rootfs...");
|
|
|
|
// Create the cpio archive with progress
|
|
let cpio_data = create_cpio_archive(rootfs, &pb)?;
|
|
let total_bytes = cpio_data.len() as u64;
|
|
let file_count = pb.position();
|
|
|
|
// Finish the scanning progress bar
|
|
pb.finish_and_clear();
|
|
|
|
// Create progress bar for compression
|
|
let compress_pb = ProgressBar::new(total_bytes);
|
|
compress_pb.set_style(ProgressStyle::default_bar()
|
|
.template("{spinner:.green} Compressing initramfs... {bytes}/{total_bytes} ({eta})")
|
|
.unwrap()
|
|
.progress_chars("##-"));
|
|
|
|
// Compress with gzip, writing in chunks to update progress
|
|
let mut output_file = std::fs::File::create(&initramfs_path)
|
|
.context("Failed to create initramfs file")?;
|
|
|
|
let mut encoder = flate2::write::GzEncoder::new(&mut output_file, flate2::Compression::fast());
|
|
|
|
// Write in 64KB chunks to provide progress updates
|
|
const CHUNK_SIZE: usize = 64 * 1024;
|
|
let mut offset = 0;
|
|
while offset < cpio_data.len() {
|
|
let end = std::cmp::min(offset + CHUNK_SIZE, cpio_data.len());
|
|
encoder.write_all(&cpio_data[offset..end])
|
|
.context("Failed to write compressed initramfs")?;
|
|
offset = end;
|
|
compress_pb.set_position(offset as u64);
|
|
}
|
|
encoder.finish()
|
|
.context("Failed to finalize gzip compression")?;
|
|
|
|
// Clear progress bar and print message only in verbose mode
|
|
compress_pb.finish_and_clear();
|
|
veprintln!("Initramfs created: {} bytes -> {} bytes compressed, {} files",
|
|
total_bytes, output_file.metadata().ok().map(|m| m.len()).unwrap_or(0), file_count);
|
|
|
|
Ok(initramfs_path)
|
|
}
|
|
|
|
/// Create a newc-format cpio archive from a directory using the cpio crate
|
|
fn create_cpio_archive(rootfs: &Path, pb: &ProgressBar) -> Result<Vec<u8>> {
|
|
let mut archive = Vec::new();
|
|
|
|
// Track seen inodes to handle hard links properly
|
|
let mut seen_inodes = std::collections::HashMap::new();
|
|
|
|
// Collect all entries with their data
|
|
let entries = collect_entries(rootfs, rootfs, pb, &mut seen_inodes)?;
|
|
|
|
// Collect entry names for checking existence later
|
|
let entry_names: Vec<&str> = entries.iter().map(|(n, _, _, _, _)| n.as_str()).collect();
|
|
|
|
pb.set_message("Writing initramfs...");
|
|
|
|
// Write each entry using the cpio crate
|
|
for (name, mode, mtime, nlink, data) in &entries {
|
|
let file_size = data.len() as u32;
|
|
|
|
let builder = NewcBuilder::new(name)
|
|
.mode(*mode)
|
|
.uid(0)
|
|
.gid(0)
|
|
.nlink(*nlink)
|
|
.mtime(*mtime);
|
|
|
|
// Write header and get a writer
|
|
let mut writer = builder.write(&mut archive, file_size);
|
|
|
|
// Write the file content
|
|
writer.write_all(data)
|
|
.context("Failed to write file content to cpio archive")?;
|
|
|
|
// Finish this entry (returns the underlying writer)
|
|
writer.finish()
|
|
.context("Failed to finish cpio entry")?;
|
|
}
|
|
|
|
// Add essential device nodes for serial console
|
|
// These are character devices (mode 0o020xxx)
|
|
let device_nodes = [
|
|
// /dev/ttyS0 - serial console (major 4, minor 64)
|
|
("dev/ttyS0", 0o020644, 4, 64),
|
|
// /dev/null (major 1, minor 3)
|
|
("dev/null", 0o020644, 1, 3),
|
|
// /dev/tty - controlling terminal (major 5, minor 0)
|
|
("dev/tty", 0o020666, 5, 0),
|
|
];
|
|
|
|
for (name, mode, major, minor) in device_nodes {
|
|
// Check if this device node already exists in the archive
|
|
if entry_names.contains(&name) {
|
|
continue;
|
|
}
|
|
|
|
let builder = NewcBuilder::new(name)
|
|
.mode(mode)
|
|
.uid(0)
|
|
.gid(0)
|
|
.nlink(1)
|
|
.mtime(0)
|
|
.rdev_major(major)
|
|
.rdev_minor(minor);
|
|
|
|
// Device nodes have zero size
|
|
let writer = builder.write(&mut archive, 0);
|
|
writer.finish()
|
|
.context("Failed to finish device node entry")?;
|
|
}
|
|
|
|
// Write the trailer (takes ownership and returns the writer)
|
|
archive = newc::trailer(archive)
|
|
.context("Failed to write cpio trailer")?;
|
|
|
|
Ok(archive)
|
|
}
|
|
|
|
/// Collect all filesystem entries recursively
|
|
/// Uses a HashMap to track hard links by (device, inode) - only stores data for first occurrence
|
|
fn collect_entries(
|
|
base: &Path,
|
|
current: &Path,
|
|
pb: &ProgressBar,
|
|
seen_inodes: &mut std::collections::HashMap<(u64, u64), String>,
|
|
) -> Result<Vec<(String, u32, u32, u32, Vec<u8>)>> {
|
|
let mut entries = Vec::new();
|
|
let mut total_data: u64 = 0;
|
|
|
|
// Read directory entries
|
|
let dir_entries: Vec<_> = match std::fs::read_dir(current) {
|
|
Ok(entries) => entries.collect::<std::result::Result<_, _>>()?,
|
|
Err(e) => {
|
|
veprintln!("Warning: cannot read directory {}: {}", current.display(), e);
|
|
return Ok(entries);
|
|
}
|
|
};
|
|
|
|
for entry in dir_entries {
|
|
let path = entry.path();
|
|
|
|
// Get metadata
|
|
let metadata = match std::fs::symlink_metadata(&path) {
|
|
Ok(m) => m,
|
|
Err(e) => {
|
|
veprintln!("Warning: skipping {} due to metadata error: {}", path.display(), e);
|
|
continue;
|
|
}
|
|
};
|
|
|
|
// Increment progress counter
|
|
pb.inc(1);
|
|
|
|
let file_type = metadata.file_type();
|
|
|
|
// Determine mode (file type + permissions from filesystem)
|
|
let mode = if file_type.is_dir() {
|
|
// Directory: preserve permissions, ensure at least rwx for owner
|
|
0o040000 | (metadata.mode() & 0o7777)
|
|
} else if file_type.is_symlink() {
|
|
0o120777 // symlink with rwxrwxrwx (permissions don't matter for symlinks)
|
|
} else if file_type.is_file() {
|
|
// Regular file: preserve permissions from filesystem
|
|
0o100000 | (metadata.mode() & 0o7777)
|
|
} else {
|
|
continue; // Skip other types (sockets, fifos, etc.)
|
|
};
|
|
|
|
// Build the entry name (relative path from base)
|
|
let relative = path.strip_prefix(base).unwrap();
|
|
let entry_name = relative.to_string_lossy().into_owned();
|
|
|
|
// Handle hard links: only store data for first occurrence
|
|
let (data, nlink) = if file_type.is_file() && metadata.nlink() > 1 {
|
|
// This file has multiple hard links - check if we've seen it before
|
|
let inode_key = (metadata.dev(), metadata.ino());
|
|
|
|
if let Some(_first_path) = seen_inodes.get(&inode_key) {
|
|
// We've seen this inode before - create a hard link entry with no data
|
|
(Vec::new(), metadata.nlink() as u32)
|
|
} else {
|
|
// First occurrence - read the data and record this inode
|
|
seen_inodes.insert(inode_key, entry_name.clone());
|
|
let data = match std::fs::read(&path) {
|
|
Ok(data) => data,
|
|
Err(e) => {
|
|
veprintln!("Warning: cannot read file {}: {}", path.display(), e);
|
|
continue;
|
|
}
|
|
};
|
|
(data, metadata.nlink() as u32)
|
|
}
|
|
} else if file_type.is_file() {
|
|
// Regular file with nlink=1
|
|
let data = match std::fs::read(&path) {
|
|
Ok(data) => data,
|
|
Err(e) => {
|
|
veprintln!("Warning: cannot read file {}: {}", path.display(), e);
|
|
continue;
|
|
}
|
|
};
|
|
(data, 1)
|
|
} else if file_type.is_symlink() {
|
|
match std::fs::read_link(&path) {
|
|
Ok(target) => (target.to_string_lossy().into_owned().into_bytes(), 1),
|
|
Err(e) => {
|
|
veprintln!("Warning: cannot read symlink {}: {}", path.display(), e);
|
|
continue;
|
|
}
|
|
}
|
|
} else {
|
|
// Directory
|
|
(Vec::new(), 2)
|
|
};
|
|
|
|
total_data += data.len() as u64;
|
|
entries.push((entry_name, mode, metadata.mtime() as u32, nlink, data));
|
|
|
|
// Recurse into directories
|
|
if file_type.is_dir() {
|
|
let mut sub_entries = collect_entries(base, &path, pb, seen_inodes)?;
|
|
for (_, _, _, _, d) in &sub_entries {
|
|
total_data += d.len() as u64;
|
|
}
|
|
entries.append(&mut sub_entries);
|
|
}
|
|
}
|
|
|
|
// Only print summary for the root directory
|
|
if current == base {
|
|
veprintln!("Collected {} entries, {} bytes total data", entries.len(), total_data);
|
|
}
|
|
Ok(entries)
|
|
}
|