diff --git a/doc/src/developer/tests.md b/doc/src/developer/tests.md index 8f578030..50831155 100644 --- a/doc/src/developer/tests.md +++ b/doc/src/developer/tests.md @@ -40,8 +40,9 @@ in a fresh core and survive a resume/re-pause cycle. The generic `get_vcpu_count configured `cpus`. **Requirements** -- a VirtualBox build with the MachineDebugger patches (MachineDebugger method) or with VBoxVMI - (VBoxVMI method), its kernel modules loaded +- a VirtualBox build with the MachineDebugger patches (MachineDebugger method), VBoxVMI + (VBoxVMI method), or both patch series on a test-only combined branch (cross-method test), with + that build's kernel modules loaded - a VM with an online snapshot (saved state), e.g. a live Alpine ISO guest at its login prompt **Configuration** (environment variables) @@ -89,6 +90,50 @@ second client. The other filters cover `pause`, `virtualbox_vcpu_count_matches_vmm`. With `VBOX_TEST_CPUS=2`, the register tests read every vCPU and write the last one. +### MachineDebugger/VBoxVMI cross-method consistency + +The `cross_method_consistency` inventory test opens MachineDebugger and VBoxVMI +concurrently against the same VM process, pauses it through MachineDebugger, and compares every +register on every vCPU and every byte in the live PGM RAM ranges directly between the two methods. +It checks both drivers' maximum physical address against the end of those ranges, reports the exact +byte count above 4 GiB, and uses no core dump. It then writes the same safe zero-page locations used +by the other VirtualBox write test and the last vCPU's `SYSENTER_ESP`/`SYSENTER_EIP` in both +directions. Every write is read independently through the other method. Originals are restored and +verified through both methods before resume; an unwind guard repeats restoration, and the standard +teardown powers off and restores the base snapshot. + +One VirtualBox process must implement both APIs. The long-lived `vmi/machinedebugger` and +`vmi/vboxvmi` branches intentionally stay separate; use the test-only +`Wenzel/virtualbox` branch `vmi/cross-method-test`, which merges the completed MachineDebugger +patches into `vmi/vboxvmi`: + +~~~ +git -C /path/to/virtualbox fetch fork +git -C /path/to/virtualbox worktree add /path/to/virtualbox-vmi fork/vmi/cross-method-test +/path/to/vbox-dev/build.sh /path/to/virtualbox-vmi +~~~ + +Compile the integration binary with both access methods. Run it on the host with +`VBOX_APP_HOME` pointing at that same combined build: the VM's VBoxVMI server and +MachineDebugger's dynamically loaded `VBoxC.so` must come from one build. +`VBOX_TEST_METHOD=vboxvmi` enables the server and supplies its one client to the test. +`VBOX_TEST_CROSS_METHOD=1` opts into this destructive live proof; without it an unfiltered run +prints an explicit skip, and ordinary `virtualbox_` filters do not select it. The test requires +the exact `MICROVMI_TEST_FILTER=cross_method_consistency` and two vCPUs. Run both the 512 MiB case +and the 5120 MiB case, which must report nonzero bytes and a selected write page above 4 GiB: + +~~~ +cargo test --features 'virtualbox-api virtualbox-vmi' --test integration --no-run +BIN=target/debug/deps/integration- +COMMON='VBOX_APP_HOME=/path/to/virtualbox-vmi/out/linux.amd64/release/bin VBOX_USER_HOME=/path/to/vbox-home VBOX_TEST_METHOD=vboxvmi VBOX_TEST_CROSS_METHOD=1 VBOX_TEST_CPUS=2 MICROVMI_TEST_FILTER=cross_method_consistency MICROVMI_TEST_TIMEOUT=180 VBOX_TEST_CORE_DIR=/path/to/disk-backed/core-dir' +flock -o /path/to/vbox-dev/.vm.lock env $COMMON VBOX_TEST_MEMORY_MB=512 $BIN +flock -o /path/to/vbox-dev/.vm.lock env $COMMON VBOX_TEST_MEMORY_MB=5120 $BIN +~~~ + +Run only a binary built from the matching libmicrovmi checkout. Teardown runs after setup or test +failure; do not interrupt teardown. A completed run leaves the VM powered off with the base +snapshot restored. + If cargo runs in a container without VirtualBox, build the test binary there and run it on the host: ~~~ diff --git a/tests/common/filter.rs b/tests/common/filter.rs new file mode 100644 index 00000000..b2b73530 --- /dev/null +++ b/tests/common/filter.rs @@ -0,0 +1,5 @@ +pub const CROSS_METHOD_TEST: &str = "cross_method_consistency"; + +pub fn is_selected(name: &str, filter: Option<&str>) -> bool { + filter.is_none_or(|filter| name.contains(filter)) +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 01c39d30..ab6f4219 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,5 +1,6 @@ pub mod config; pub mod context; +pub mod filter; #[cfg(feature = "kvm")] mod kvm; #[cfg(all( diff --git a/tests/integration.rs b/tests/integration.rs index 7af5bb46..82c9ad7c 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -8,6 +8,7 @@ use std::time::Duration; use colored::*; use common::context::init_context; +use common::filter::is_selected; use tests::IntegrationTest; mod common; @@ -26,10 +27,7 @@ fn main() { let (mut ran, mut failed) = (0, 0); // for each test for test in inventory::iter:: { - if filter - .as_ref() - .is_some_and(|f| !test.name.contains(f.as_str())) - { + if !is_selected(test.name, filter.as_deref()) { continue; } ran += 1; diff --git a/tests/integration_filter.rs b/tests/integration_filter.rs new file mode 100644 index 00000000..4a915960 --- /dev/null +++ b/tests/integration_filter.rs @@ -0,0 +1,12 @@ +#![cfg(all(feature = "virtualbox-api", feature = "virtualbox-vmi"))] + +#[path = "common/filter.rs"] +mod filter; + +use filter::{is_selected, CROSS_METHOD_TEST}; + +#[test] +fn cross_method_requires_its_exact_filter() { + assert!(!is_selected(CROSS_METHOD_TEST, Some("virtualbox_"))); + assert!(is_selected(CROSS_METHOD_TEST, Some(CROSS_METHOD_TEST))); +} diff --git a/tests/tests/virtualbox.rs b/tests/tests/virtualbox.rs index 0dcc5d05..a5910af3 100644 --- a/tests/tests/virtualbox.rs +++ b/tests/tests/virtualbox.rs @@ -1048,3 +1048,424 @@ inventory::submit!(IntegrationTest { name: "virtualbox_write_registers_survive_resume", test_fn: write_registers_survive_resume }); + +/// Both VirtualBox access methods attached to one VM, compared without a core dump. +#[cfg(all(feature = "virtualbox-api", feature = "virtualbox-vmi"))] +mod cross_method { + use super::*; + use crate::common::filter::CROSS_METHOD_TEST; + use microvmi::api::params::{CommonInitParams, DriverInitParams, VBoxInitParams}; + use microvmi::api::DriverType; + + fn read_exact(drv: &dyn Introspectable, method: &str, addr: u64, buf: &mut [u8]) { + let mut bytes_read = 0; + drv.read_physical(addr, buf, &mut bytes_read) + .unwrap_or_else(|error| { + panic!( + "{} failed to read RAM at {:#x} after {} bytes: {}", + method, addr, bytes_read, error + ) + }); + assert_eq!( + bytes_read, + buf.len() as u64, + "{} short read at {:#x}", + method, + addr + ); + } + + fn read_page(drv: &dyn Introspectable, method: &str, addr: u64) -> Vec { + let mut page = vec![0; PAGE as usize]; + read_exact(drv, method, addr, &mut page); + page + } + + fn assert_page(drv: &dyn Introspectable, method: &str, addr: u64, expected: &[u8]) { + let actual = read_page(drv, method, addr); + if let Some(offset) = actual + .iter() + .zip(expected) + .position(|(actual, expected)| actual != expected) + { + panic!( + "{} RAM differs at {:#x}: {:#04x} != {:#04x}", + method, + addr + offset as u64, + actual[offset], + expected[offset] + ); + } + } + + fn assert_registers( + drv: &dyn Introspectable, + method: &str, + vcpu: u16, + expected: &X86Registers, + ) { + assert_eq!( + x86_registers(drv, vcpu), + *expected, + "{} registers differ on vCPU {}", + method, + vcpu + ); + } + + fn pattern(addr: u64, salt: u64) -> Vec { + let mut state = (addr ^ salt) | 1; + (0..PAGE) + .map(|_| { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + state as u8 + }) + .collect() + } + + /// Restores through both methods before `ResumeVm` resumes, including during unwinding. The + /// outer VirtualBox context then powers off and restores the base snapshot. + struct CrossMethodRestore<'a> { + machine_debugger: &'a dyn Introspectable, + vboxvmi: &'a dyn Introspectable, + pages: Vec<(u64, Vec)>, + vcpu: u16, + registers: X86Registers, + armed: bool, + } + + impl CrossMethodRestore<'_> { + fn restore_with(&self, drv: &dyn Introspectable, method: &str) { + for (addr, original) in &self.pages { + if let Err(error) = drv.write_physical(*addr, original) { + eprintln!("{} failed to restore RAM at {:#x}: {}", method, addr, error); + } + } + if let Err(error) = + drv.write_registers(self.vcpu, Registers::X86(self.registers.clone())) + { + eprintln!( + "{} failed to restore vCPU {} registers: {}", + method, self.vcpu, error + ); + } + } + } + + impl Drop for CrossMethodRestore<'_> { + fn drop(&mut self) { + if self.armed { + let _ = try_vboxmanage(&["controlvm", &vm_name(), "pause"]); + self.restore_with(self.machine_debugger, "MachineDebugger"); + self.restore_with(self.vboxvmi, "VBoxVMI"); + } + } + } + + fn init_machine_debugger() -> Box { + microvmi::init( + Some(DriverType::VirtualBox), + Some(DriverInitParams { + common: Some(CommonInitParams { vm_name: vm_name() }), + virtualbox: Some(VBoxInitParams::MachineDebugger), + ..Default::default() + }), + ) + .expect("failed to attach MachineDebugger") + } + + /// On one paused VM, compare every register of every vCPU and every byte of all PGM RAM ranges + /// directly between the methods. Then write safe RAM pages and SYSENTER registers in both + /// directions, with independent readback and exact restoration through both methods. + fn consistency(vboxvmi: Box) { + let filter = env::var("MICROVMI_TEST_FILTER").ok(); + if !matches!(env::var("VBOX_TEST_CROSS_METHOD").as_deref(), Ok("1")) { + if filter.is_none() { + println!( + "SKIPPED: set VBOX_TEST_CROSS_METHOD=1 and \ + MICROVMI_TEST_FILTER={}", + CROSS_METHOD_TEST + ); + return; + } + panic!("cross-method test requires VBOX_TEST_CROSS_METHOD=1"); + } + assert_eq!( + filter.as_deref(), + Some(CROSS_METHOD_TEST), + "cross-method test requires the exact MICROVMI_TEST_FILTER={}", + CROSS_METHOD_TEST + ); + assert!( + matches!(env::var("VBOX_TEST_METHOD").as_deref(), Ok("vboxvmi")), + "cross-method test requires VBOX_TEST_METHOD=vboxvmi" + ); + let mut machine_debugger = init_machine_debugger(); + let _resume = ResumeVm; + machine_debugger.pause().unwrap(); + assert_eq!(vm_info("VMState"), "paused"); + + let md_count = machine_debugger.get_vcpu_count().unwrap(); + let vmi_count = vboxvmi.get_vcpu_count().unwrap(); + assert_eq!(md_count, vmi_count, "vCPU counts differ"); + assert!( + md_count >= 2, + "cross-method coverage requires VBOX_TEST_CPUS=2 or more" + ); + for vcpu in 0..md_count { + let md = x86_registers(machine_debugger.as_ref(), vcpu); + let vmi = x86_registers(vboxvmi.as_ref(), vcpu); + assert_eq!(md, vmi, "complete registers differ on vCPU {}", vcpu); + } + + let ranges = ram_ranges(); + let ranges_end = ranges.iter().map(|range| range.end).max().unwrap(); + let md_max = machine_debugger.get_max_physical_addr().unwrap(); + let vmi_max = vboxvmi.get_max_physical_addr().unwrap(); + assert_eq!(md_max, vmi_max, "maximum physical addresses differ"); + assert_eq!(md_max, ranges_end, "driver maximum does not cover PGM RAM"); + + // The same safe locations as the core-based write test. Record the nearest all-zero page + // below each target while the complete direct comparison already has every RAM page. + let top = ranges_end; + let top_below_4g = ranges + .iter() + .map(|range| range.end) + .filter(|&end| end <= FOUR_GIB) + .max() + .unwrap(); + let write_targets = [0x10000, top / 2, top_below_4g, top]; + let target_ranges: Vec<(u64, u64)> = write_targets + .iter() + .map(|&target| { + let range = ranges + .iter() + .find(|range| range.start < target && target <= range.end) + .unwrap_or_else(|| panic!("{:#x} is not in a RAM range", target)); + (target & !(PAGE - 1), range.start.max(PAGE)) + }) + .collect(); + let mut write_pages = vec![None; write_targets.len()]; + + let mut md = vec![0; CHUNK as usize]; + let mut vmi = vec![0; CHUNK as usize]; + let (mut compared, mut compared_above_4g) = (0u64, 0u64); + for range in &ranges { + let mut addr = range.start; + while addr < range.end { + let len = CHUNK.min(range.end - addr) as usize; + read_exact( + machine_debugger.as_ref(), + "MachineDebugger", + addr, + &mut md[..len], + ); + read_exact(vboxvmi.as_ref(), "VBoxVMI", addr, &mut vmi[..len]); + if let Some(offset) = md[..len] + .iter() + .zip(&vmi[..len]) + .position(|(md, vmi)| md != vmi) + { + panic!( + "methods differ at guest-physical {:#x}: {:#04x} != {:#04x}", + addr + offset as u64, + md[offset], + vmi[offset] + ); + } + let (pages, remainder) = md[..len].as_chunks::<{ PAGE as usize }>(); + debug_assert!(remainder.is_empty()); + for (offset, page) in pages.iter().enumerate() { + if page.iter().any(|&byte| byte != 0) { + continue; + } + let page = addr + offset as u64 * PAGE; + for ((target, first), selected) in target_ranges.iter().zip(&mut write_pages) { + if *first <= page && page < *target { + *selected = Some(page); + } + } + } + compared += len as u64; + compared_above_4g += (addr + len as u64).saturating_sub(addr.max(FOUR_GIB)); + addr += len as u64; + } + } + let ram_bytes: u64 = ranges.iter().map(|range| range.end - range.start).sum(); + let ram_above_4g: u64 = ranges + .iter() + .map(|range| range.end.saturating_sub(range.start.max(FOUR_GIB))) + .sum(); + assert_eq!(compared, ram_bytes); + assert_eq!(compared_above_4g, ram_above_4g); + if vm_info("memory").parse::().unwrap() > 4096 { + assert!( + md_max > FOUR_GIB, + "maximum physical address is not above 4 GiB" + ); + assert!( + compared_above_4g > 0, + "no RAM bytes above 4 GiB were compared" + ); + } + println!( + "cross-method compared {} RAM bytes ({} above 4 GiB), max physical address {:#x}, {} vCPUs", + compared, compared_above_4g, md_max, md_count + ); + print!("PGM RAM ranges"); + for range in &ranges { + print!(" {:#x}..{:#x}", range.start, range.end); + } + println!(); + + let mut page_addrs: Vec = write_pages + .into_iter() + .zip(write_targets) + .map(|(page, target)| { + page.unwrap_or_else(|| panic!("no zero page below {:#x}", target)) + }) + .collect(); + page_addrs.sort_unstable(); + page_addrs.dedup(); + if ram_above_4g > 0 { + assert!( + page_addrs.iter().any(|&addr| addr >= FOUR_GIB), + "no write page above 4 GiB" + ); + } + let pages: Vec<(u64, Vec)> = page_addrs + .iter() + .map(|&addr| { + ( + addr, + read_page(machine_debugger.as_ref(), "MachineDebugger", addr), + ) + }) + .collect(); + for (addr, original) in &pages { + assert_page(vboxvmi.as_ref(), "VBoxVMI", *addr, original); + } + let vcpu = md_count - 1; + let registers = x86_registers(machine_debugger.as_ref(), vcpu); + assert_registers(vboxvmi.as_ref(), "VBoxVMI", vcpu, ®isters); + let mut restore = CrossMethodRestore { + machine_debugger: machine_debugger.as_ref(), + vboxvmi: vboxvmi.as_ref(), + pages, + vcpu, + registers, + armed: true, + }; + + for (addr, original) in &restore.pages { + let changed = pattern(*addr, 0x004d_442d_3e56_4d49); + machine_debugger.write_physical(*addr, &changed).unwrap(); + assert_page(vboxvmi.as_ref(), "VBoxVMI", *addr, &changed); + vboxvmi.write_physical(*addr, original).unwrap(); + assert_page( + machine_debugger.as_ref(), + "MachineDebugger", + *addr, + original, + ); + assert_page(vboxvmi.as_ref(), "VBoxVMI", *addr, original); + + let changed = pattern(*addr, 0x0056_4d49_2d3e_4d44); + vboxvmi.write_physical(*addr, &changed).unwrap(); + assert_page( + machine_debugger.as_ref(), + "MachineDebugger", + *addr, + &changed, + ); + } + + let mut changed = restore.registers.clone(); + changed.sysenter_esp ^= 8; + changed.sysenter_eip ^= 8; + machine_debugger + .write_registers(vcpu, Registers::X86(changed.clone())) + .unwrap(); + assert_registers(vboxvmi.as_ref(), "VBoxVMI", vcpu, &changed); + vboxvmi + .write_registers(vcpu, Registers::X86(restore.registers.clone())) + .unwrap(); + assert_registers( + machine_debugger.as_ref(), + "MachineDebugger", + vcpu, + &restore.registers, + ); + assert_registers(vboxvmi.as_ref(), "VBoxVMI", vcpu, &restore.registers); + + changed = restore.registers.clone(); + changed.sysenter_esp ^= 16; + changed.sysenter_eip ^= 16; + vboxvmi + .write_registers(vcpu, Registers::X86(changed.clone())) + .unwrap(); + assert_registers(machine_debugger.as_ref(), "MachineDebugger", vcpu, &changed); + + // Final restoration is deliberately performed through both methods and verified through + // both, so either method can recover the state left by the other. + for (addr, original) in &restore.pages { + vboxvmi.write_physical(*addr, original).unwrap(); + assert_page( + machine_debugger.as_ref(), + "MachineDebugger", + *addr, + original, + ); + assert_page(vboxvmi.as_ref(), "VBoxVMI", *addr, original); + machine_debugger.write_physical(*addr, original).unwrap(); + assert_page( + machine_debugger.as_ref(), + "MachineDebugger", + *addr, + original, + ); + assert_page(vboxvmi.as_ref(), "VBoxVMI", *addr, original); + } + vboxvmi + .write_registers(vcpu, Registers::X86(restore.registers.clone())) + .unwrap(); + assert_registers( + machine_debugger.as_ref(), + "MachineDebugger", + vcpu, + &restore.registers, + ); + assert_registers(vboxvmi.as_ref(), "VBoxVMI", vcpu, &restore.registers); + machine_debugger + .write_registers(vcpu, Registers::X86(restore.registers.clone())) + .unwrap(); + assert_registers( + machine_debugger.as_ref(), + "MachineDebugger", + vcpu, + &restore.registers, + ); + assert_registers(vboxvmi.as_ref(), "VBoxVMI", vcpu, &restore.registers); + restore.armed = false; + let pages: Vec = page_addrs + .iter() + .map(|addr| format!("{:#x}", addr)) + .collect(); + println!( + "cross-method writes restored on vCPU {} and RAM pages {}", + vcpu, + pages.join(", ") + ); + drop(restore); + + machine_debugger.resume().unwrap(); + assert_eq!(vm_info("VMState"), "running"); + } + + inventory::submit!(IntegrationTest { + name: CROSS_METHOD_TEST, + test_fn: consistency + }); +}